MOJ — API v1 (referência) — MOJ docs

MOJ — API v1 (referência)

Base: /api/v1. Roteador único: server/api/v1/router.shhandlers/<rota>.sh. Aviso de CLI desatualizada (lib/cli-version.sh): toda resposta a uma CLI (UA moj[-tool]/<build>) leva X-Moj-Cli-Status: current|outdated|dev e X-Moj-Cli-Latest: <build> (referência = web/moj.build, o mesmo do moj version; build = <git-short>-<AAAAMMDD>, comparada pela data); CLI ANTIGA (UA curl/* + Bearer, sem marcador — não lê cabeçalho) recebe X-Moj-Cli-Status: legacy e a dica "rode moj update" ANEXADA à error.message. Navegador e curl cru: nada. A CLI avisa no stderr uma vez por dia.

Auth: Authorization: Bearer <token>. Respostas JSON com envelope {success:true, …} ou {success:false, error:{message,code}} + status HTTP correto. Histórico e placar são TXT cru. Horários em EPOCH. IDs validados contra path-traversal.

Auth

Rota Método Auth I/O
/auth/login?contest=<c> POST body {username,password}{token,logged_in,username,name,contest,server_utc}. Contest (≠ treino) inclui o kit da submissão OFFLINE do moj-comp: offline_pubkey_pem (pública RSA-4096 do contest, gerada lazy em contests/<c>/secrets/) e beacon (carimbo de tempo assinado — ver /contest/beacon). server_utc permite à CLI medir o desvio do relógio local.
/auth/status?contest=<c> GET Bearer {logged_in,login,name,contest,is_admin,is_judge,is_staff,is_cstaff,is_chief,has_photo} (.cjudge = juiz-chefe → is_judge:true,is_chief:true; .cstaff = chefe de sede → is_cstaff:true, sem herdar is_staff). has_photo existe p/ o avatarEl NÃO pedir a foto de quem não tem: o avatar do cabeçalho aparece em toda página e cada 404 desses é um fork de bash (5.712 no dia 24/08/2026, 54% de todos os 404). Mesmo campo em /index/open_training (top_users[] e recent_solved[].user) e em /treino/problem-stats
/auth/logout POST Bearer {logged_out:true} — apaga o arquivo de sessão mesmo se ela já não vale (senão o zumbi ficava p/ sempre no store)

A porta do contest (/auth/login, forçada pela API — o countdown do front é só conveniência): LOGIN_ENABLED=n403 login_disabled; antes de LOGIN_START_TIME403 login_not_open; com INSCRIÇÃO ligada (contests/<c>/registrations.json existe) quem não está no roster leva 403 not_registered (janela ainda aberta), registration_not_open ou registration_closedaquecimento INCLUSO (default): só inscrito entra em qualquer rodada. REG_WARMUP_OPEN=y no conf restaura a porta aberta durante rodada warmup (opt-in); nesse caso a promoção da oficial derruba a sessão de quem não se inscreveu (reg_sweep_unregistered) e apaga o diretório vazio. Conta de PAPEL (.admin/.judge/.cjudge/.staff/.cstaff/.mon) nunca é barrada. Alias de TIME: se o login é membro de um time inscrito, a credencial é a DELE mas a sessão é do time — a resposta traz actor (quem digitou) e is_team:true, e /auth/status, /contest/userinfo, o access.log (5ª coluna) e o var/actor-log guardam o ator. Ver lib/registration.sh.

Invariante da sessão: o token só continua valendo enquanto a CONTA existir (users/<login>/account.json do contest da sessão ou, com USERS_FROM, o da fonte compartilhada). Conta renomeada, removida ou contest apagado ⇒ 401 auth_required na primeira requisição, e o cliente cai no login. Sessão do MOJ não expira por tempo: sem essa checagem uma sessão aberta antes de uma troca de handle seguia autenticada com o login VELHO e o /submit (que faz mkdir -p no dir do usuário) recriava o diretório do nome antigo — resíduo sem account.json que ainda aparecia como "solver" nas estatísticas. Ver lib/auth.sh (_session_account_alive) e server/bin/user-merge.sh (conserto do resíduo).

Index (home)

Rota I/O
/index/news {news:[{id,title,date,summary,url}]}
/index/contests?page=N {open:[…],upcoming:[…],closed:{items:[…],page,per_page,total}} (cada item {id,title,start_time,end_time,problems_count,url,scoreboard_url,report_url?}virtual_url (/treino/virtual/?c=<id>) nos ENCERRADOS com o módulo virtual ligado, ICPC e sem freeze — conveniência do card; quem decide é o portão da API; report_url (/relatorio/<id>/) só quando o admin PUBLICOU o relatório estático (/contest/admin/report-publish) — problems_count é 0 em contest upcoming: contest por vir não revela a quantidade de problemas, mesma regra do placar pré-início + registration:{opens_at,closes_at,late_until,url} quando o contest tem INSCRIÇÃO ligada — o cartão do front decide "aberta/atrasada/encerrada" pelo relógio do cliente, sem duplicar a regra do lib/registration.sh). Encerrados paginados (20/pág); ?all=1 devolve todos (usado pela página de arquivo /contests/). Contest SUPER SECRETO (conf SECRET=1) não aparece em nenhuma das três listas (nem no /index/status, que também omite o nome na fila por lista).
/index/open_training {top_users:[…],recent_solved:[…],most_solved_week:[…],most_solved_prev_week:[{problem_id,problem_title,solved_count,url}],most_used_editor_prev_week:{top:{editor,count}|null,total,ranking:[{editor,count}]}} (prev_week=resolvedores distintos por problema; editor=mais usado nas aceitas da semana passada, web ou editor declarado; perfil PRIVADO não entra em top_users/recent_solved — o filtro pula p/ o próximo). Cache var/open-training.json por EVENTO (.score-dirty OU .treino-list-dirty mais novos = regenera; despublicado some da home; piso 5 min sob rajada; flock)

Treino

Rota Auth I/O
/treino/problems array [{id,title,tags,collections,statement_langs,solved_count,attempted_count,user_rate,difficulty,dirt,public_at?}] (statement_langs = idiomas do enunciado, do sidecar; a lista mostra o selo "EN ES" quando >1) (difficulty = rótulo CANÔNICO veasy|easy|med|hard|new pela taxa POR USUÁRIO user_rate = resolveram ÷ tentaram — faixas .9/.7/.5 em lib/difficulty.sh, fonte única do sistema desde a issue #30; dirt = métrica do resolver ICPC, (submissões de quem resolveu até o 1º AC − ACs) ÷ essas submissões, do tries_to_ac do metrics.json; null no legado json-count) (collections = .moj-meta.json do pacote, um problema pode estar em várias; public_at = epoch da 1ª publicação, vindo do índice de donos — AUSENTE quando desconhecido; alimenta a ordenação "Novidades" do treino). Contagens do STORE NOVO: agregação de users/*/metrics.json (.solved/.attempted, 1 usuário = 1 por problema), sobreposta à base legada var/json-count/ quando existir. Cache var/problems.json invalidado POR EVENTO (gerador server/score/treino-list-gen.sh): composição da lista = stamp var/.treino-list-dirty (foreground sob flock); contagens = var/.score-dirty + piso de 10 min (refresh em BACKGROUND, serve o stale); TTL de 60 min só rede de segurança
/treino/trending top-10 problemas por submissões (todas) nos últimos 7 dias (janela móvel), p/ o estado inicial do Treino Livre: {success,window_days:7,generated_at,problems:[{id,title,count,url}]} (ordenado por count desc). Anônimo; problema privado NÃO entra (_private: json só em jsons-private/). Cache var/trending.json por EVENTO (.score-dirty/.treino-list-dirty) com piso LONGO de 6h (varre o history de ~927 contas; a janela é semanal) + flock
/treino/problem?id=<id> {id,title,author,statement_html_b64,time_limits,tags,collections,languages,statement_langs,statements,samples} (samples = [{name,input,output}], os exemplos do enunciado como texto — a MESMA seleção que o HTML mostra, nunca teste oculto; exemplo acima de 4 MB vem como {name,size,too_big:true} sem os bytes; 2026-09-16) (statement_langs = idiomas do enunciado, ["pt"] no mínimo, português primeiro; statements = {"<lang>":{title,html_b64}} das traduções, ausente sem tradução — o PT segue em title/statement_html_b64; a página mostra um chip por idioma; ver PACOTE.md "Idiomas") (author = arquivo author do pacote, verbatim; vários autores juntados por , ; vazio se ausente; collections = coleções do .moj-meta.json; languages = ids de linguagem de submissão permitidos deste problema, [] = todas as PADRÃO — o front filtra o dropdown por essa lista; linguagens EXÓTICAS/opt-in (pddl/grepe/sas/l/lpp/downward) só aparecem quando o problema as DECLARA aqui). Registra problem-view no log de atividade (load_session soft: Bearer presente = login, sem = anon; a rota segue pública)
/treino/admin/activity-log GET .admin feed COMPLETO do treino (6 fontes no instante exato): login (access.log) · submit (history) · verdict (results finalized_at) · read (activity-YYYY-MM.log: problem-view/log-view/source-download) · admin (admin-audit sem ruído de máquina) · calib (tl-report/calib-report, EXCLUÍDO por default — ?kinds=calib inclui). Filtros since/until (epoch), kinds (csv), user, action, limit (≤5000). format=csv = download do range INTEIRO filtrado (Content-Disposition; cabeçalho epoch,datahora,tipo,quem,acao,detalhes,ip) p/ análise externa. Aba 📜 Atividade do admin do treino
/treino/solvetry?user=<u> opc {solved:[ids],attempted:[ids]}
/treino/history?id=<id> Bearer TXT 7 campos tempo:user:probid:lang:verdito:epoch:subid. Veredicto SEMPRE canônico (lib/verdict.sh; pendentes/strings desconhecidas intactos) — o detalhe (testes/pontos/grupos) vem do /submission/summary
/treino/history-full?user=<u> opc TXT 7 campos (todo o histórico). Veredicto canônico (idem acima) — visitante do perfil público vê só o rótulo, sem resumo (summary é só do dono)
/treino/profile Bearer GET: perfil + cota de username + telegram:{linked,username,linked_at} (vínculo do próprio login; sem o telegram_id) · POST {name?,university?}
/treino/profile/password Bearer POST {old_password,new_password}
/treino/profile/username Bearer POST {new_username}{updated,new_username,username_changes_used,username_changes_remaining,sessions_updated}. máx. 2/ano, cascata nos arquivos de controle incluindo as SESSÕES (rename_contest_sessions: TODAS as sessões daquele login — outra aba, outro dispositivo, token do moj-cli e as de contests que herdam os usuários via USERS_FROM — passam a valer com o nome novo; sessions_updated = quantas; ninguém é deslogado) e as ORGs (orgs_rename_login: o login troca em members/admins de todas; o NOME da org — inclusive a implícita antiga, que vira comum — não muda: é o prefixo dos ids). A POSSE segue o rename (2026-09-18, lib/owner-rename.sh): dono de problema (.moj-meta.json + índice + overlay), de contest (contests/<c>/owner), de coleção e as permissões de criar contest passam ao login novo — o índice/contests/coleções na hora, os metas dos pacotes (1 commit por problema) em background. Antes o owner ficava no login antigo: os problemas sumiam de "Meus" e, como owner também concede acesso, ficava uma posse apontando p/ um login inexistente. O autor dos commits antigos no git não muda. Sufixo de papel é PRESERVADO: sufixo(novo)==sufixo(atual) — .admin troca p/ outro.admin (400 uname_role_suffix se tentar derrubar o sufixo; uname_reserved se usuário comum tentar assumir um)
/treino/profile?user=<u> opc GET visão pública (respeita privacidade): {login,name,university,favorite_editor,has_photo,is_public,created_at} (created_at=epoch de criação da conta, p/ o "membro desde" do perfil; a visão do dono também o traz — que inclui ainda managed:{minor,by,birthdate,note,expires_at}|null p/ conta GERIDA); POST aceita também favorite_editor, profile_public (400 managed_minor se conta gerida de menor tentar tornar público). Conta gerida de MENOR é sempre privada (profile_is_public corta perfil/foto/history/listas da home); link-start do Telegram → 403 managed_minor; login com .managed.expires_at vencido → 403 account_expired
/treino/contest-registration?contest=<c> Bearer INSCRIÇÃO do próprio login num contest que usa as contas do treino (USERS_FROM=treino): individual ou em TIME de até REG_TEAM_MAX (3) contas EXISTENTES. Fica AQUI (e não no contest) porque o token é por ORIGEM — <id>.moj… não enxerga a sessão do treino. GET → {enabled, contest, contest_name, start_time, end_time, window:{state:soon|open|late|closed,opens_at,closes_at,late_until,official_start,official_round}, round_kind, gate_active, team_max, teams_allowed, me:{kind:none|individual|team,team?,cohort?,univ?,ai?,flag?}, team:{login,name,captain,members[],invited[]}|null, invites:[{login,name,captain,members}], totals}. POST {contest,action}: register {univ?,ai?,flag?} (a meta pode vir junto; flag inválida = 400 SEM inscrever) · individual-meta {univ?,ai?,flag?} (inscrito INDIVIDUAL declara/edita universidade/IA/bandeira — paridade com o team-meta; vai p/ a entry do roster e materializa no .team do overlay, então placar/🤖/bandeira funcionam igual ao time) · team-create {name,univ?,ai?,flag?} · team-invite {login} (o mojinho manda DM ao convidado na hora, com o link /contests/inscricao/?c=<c> de aceitar/recusar — lib/invite-notify.sh; best-effort: sem Telegram vinculado o convite vale igual) · team-accept/team-decline {team} · team-rename {name} · team-meta {univ?,ai?,flag?} (capitão: a universidade vai em .team.univ_short — o renderer do placar exibe "[SIGLA] Nome"; flag = país ISO-2 ou estado br-xx.team.flag, a bandeira do placar, 400 flag_invalid se não casar; ai = `yes
/treino/profile/photo?user=<u> opc/Bearer GET serve png 100×100 · POST {image_b64} (redimensiona)
/treino/editors ranking dos editores favoritos declarados {editors:[{editor,count}],total}
/treino/achievements registro de CONQUISTAS do perfil {custom,version,achievements:[{id,icon,pt,en,kind,params,enabled}]} — serve var/achievements.json (gerido pela aba 🏅 do admin) quando válido, senão o default embarcado (lib/achievements-default.json); avaliação é no CLIENTE. Kinds e formato: PERFIL.md
/treino/problem-stats?id=<p> estatísticas do problema (métricas, veredictos, por-linguagem c/ solvers distintos, editores, avatares públicos; difficulty/user_rate/dirt canônicos de lib/difficulty.sh — a MESMA conta da lista /treino/problems, issue #30; acceptance_rate continua = taxa POR SUBMISSÃO, só número, nunca rótulo) + séries temporais (fuso America/Sao_Paulo): daily{YYYY-MM-DD:n} (heatmap), monthly[{m,subs,ac}], dow_hour[{dow 0=dom,hour,n}], first_ac_epochs[] (curva de resolvedores), tries[{bucket,n}]+tries_median (subs até o 1º AC), time_to_solve[{bucket,n}]+t2s_median (1ª sub→AC), facts{first_sub_epoch,last_sub_epoch,peak_day,first_solver{epoch,login?,name?}} (login/nome do 1º solver SÓ se perfil público), difficulty_percentile{harder_than_pct,cohort,success_rate} (taxa de sucesso POR USUÁRIO vs. o acervo público do var/problems.json; ranking com suavização de Laplace + midrank — coorte pequena 100% não esmaga a ponta fácil; elegível = ≥5 tentantes, null se coorte <10), runtimes[{lang,t}] (estilo Kattis: t = teste mais LENTO de cada submissão ACEITA, dos results/<subid>.json — só era newmoj) — cache por EVENTO (.score-dirty mais novo = regenera; sem submissão nova vale p/ sempre; piso 2 min sob rajada; flock)

Treino — participação virtual (refazer contest encerrado)

Desenho, regras e blindagem: docs/VIRTUAL.md. Portão único (vr_load, lib/virtual.sh) a cada requisição: módulo virtual ∧ não-secreto ∧ ICPC ∧ encerrado p/ todas as sedes ∧ placar descongelado ∧ todos os problemas públicos no treino. Portão fechado = 404 virtual_unavailable, corpo idêntico ao de contest inexistente. O enunciado NÃO tem rota aqui: vem da pública /treino/problem?id=.

Rota Método Auth I/O
/treino/virtual/info?contest=<c> GET Bearer treino {contest,title,start_time,duration,penalty_minutes,problems_count,rules:{grace_s,max_discards,schedule_max_s},me}me como em /run; conta de papel recebe me.state:"forbidden"
/treino/virtual/run?contest=<c> GET Bearer treino {contest,duration,penalty_minutes,me:{state:none|scheduled|running|judging|finished|discarded,start,end,final,discards,discards_left,can_discard,official,result,runs:[[seg,pidx,Y|N|X|?,veredicto,subid,lang]],solved,penalty,pending,now}}. Aplica as transições preguiçosas (fim do tempo ⇒ finaliza ou descarta)
/treino/virtual/run POST Bearer treino {contest,action}: start {accept:true,at?} (422 terms_required/virtual_at_invalid; 409 virtual_already = uma vez por conta) · cancel (só agendada; 409 virtual_not_scheduled) · discard (≤ 15 min OU 0 AC, máx. 2; senão 409 virtual_locked) · finish (com 0 AC e não-definitiva vira discard). Conta de papel: 403 role_forbidden. Resposta = a do GET
/treino/virtual/problems?contest=<c> GET Bearer treino {problems:[{letter,id,name,languages,statement_langs}]}languages é a whitelist do CONTEST. Sem run largada: 403 virtual_not_started
/treino/virtual/friends GET/POST Bearer treino Meus escolhidos: os virtuais que ESTE login quer ver sempre no placar virtual (amigos). UMA lista por conta, p/ todos os contests; só o próprio login lê/escreve (não há parâmetro de usuário; a rota não fala de contest, por isso não passa pelo portão). GET = {logins:[…],max:100}. POST {add?:[…],remove?:[…]} (o 📌 da linha) ou {logins:[…]} (substitui). Tira o próprio login e duplicatas; 422 friends_invalid (login fora de [A-Za-z0-9._@+-]{1,64}) · 422 friends_limit (> 100). Não confere existência da conta
/treino/virtual/feed?contest=<c> GET {version:2,contest,title,duration,penalty_minutes,problems:[{letter,name}],views:[{id,name,unranked}],teams:[[login,flag,univ_short,nome,univ_full,guest,cohort]],runs:[[seg,tidx,pidx,Y|N|X|?]]} — times do placar público FINAL; views/cohort alimentam o filtro "Placar:" da página e só trazem coortes PÚBLICAS com time no placar público (views vazio com menos de duas); pré-gzipado; 503 virtual_feed_unavailable
/treino/virtual/board?contest=<c> GET — (Bearer marca you) {virtuals:[{login,name,univ,flag,start,used,solved,penalty,official,runs:[[seg,pidx,flag]],you}]} — só participações FINALIZADAS e não removidas

Treino — cadastro & vínculo Telegram (overlay do treino)

Cadastro web-first verificado pelo Telegram (1 Telegram = 1 conta; anti-duplicata). Os endpoints verify/telegram/recover-password são autenticados pelo token do bot (Authorization: Bearer mojb_…, require_bot, segredo em run/secrets/bot.token) — o bot não loga como .admin.

Rota Auth I/O
/treino/signup/start público (POST) {login?,fullname,university?}{nonce, deep_link, expires_at}. Valida o login (bloqueia sufixo de papel) e cria um nonce (TTL 15 min). Não cria conta.
/treino/signup/status?nonce= público (GET) {status: pending|created|already_linked|linked|expired, login?}nunca devolve a senha
/treino/signup/verify bot (POST) {nonce,telegram_id,telegram_username?,first_name?,last_name?} → consome o nonce (uso único), anti-duplicata, cria+vincula (created) ou vincula conta logada (linked); devolve {status,login,password?} (senha só p/ DM)
/treino/signup/telegram bot (POST) bot-first (/participar): {telegram_id,…} → cria+vincula ancorado no telegram_id (idempotente) ou already_linked
/treino/recover-password bot (POST) {telegram_id} → resolve o login pelo vínculo, gera nova senha → {status:ok|not_linked,login?,password?}
/treino/telegram/link-start Bearer conta logada gera nonce purpose:link p/ vincular o próprio Telegram (ex.: .admin receber alertas) → {nonce,deep_link,expires_at}. UI: seção 📨 Telegram do perfil
/treino/telegram/unlink Bearer POST {} — desvincula o Telegram do PRÓPRIO login (404 not_linked sem vínculo). Cota anti conta-descartável: usuário comum desvincula no máx TELEGRAM_CHANGE_LIMIT (1)/ano (403 telegram_limit com a data da próxima; histórico em account.json telegram_changes); .admin é livre. Trocar de Telegram exige desvincular ⇒ a cota cobre a troca. A cota sai no GET /treino/profile (telegram.changes_used/limit/remaining/next_available; limit:null = livre)

Treino — painel admin (.admin, Bearer)

Acesso registra IP (X-Forwarded-For/REMOTE_ADDR) e User-Agent na sessão e em var/access.log.

Rota Método Ação
/treino/admin/sessions GET sessões ativas {count,sessions:[{login,name,ip,user_agent,login_at}]}
/treino/admin/managed-users GET contas GERIDAS (menores, sem Telegram — CONTAS-GERIDAS.md): {users:[{login,fullname,by,note,birthdate,minor,expires_at,disabled,created_at}]}
/treino/admin/managed-create POST cria contas geridas {users:[{fullname,birthdate,login?,note?,expires_at?}]} (1..500; login vazio = slug do nome com dedup; sufixo de papel recusado) → {created:[{login,password,fullname,birthdate}],skipped:[{…,reason}]}senhas só nesta resposta; audit managed-create
/treino/admin/managed-reset POST {login} (só gerida) → senha nova (user_genpass) devolvida UMA vez + derruba sessões; audit managed-reset
/treino/admin/managed-update POST {login, note?, birthdate?, expires_at?|null, disabled?} — edita .managed; disabled:true = senha-sentinela !…+derruba sessões; disabled:false = reabilita com senha nova devolvida; audit managed-update
/treino/admin/managed-remove POST {login} (só gerida) → mv p/ .removed-users/<login>-<epoch>; audit managed-remove
/treino/admin/achievements POST salva o registro de conquistas do perfil: {achievements:[…]} (valida ids únicos [a-z0-9-], kind conhecido, params por kind; grava atômico var/achievements.json; audit achievements-save) ou {restore_default:true} (remove o registro; volta ao default). Erro de validação = 400 achievements_invalid com a mensagem. Aba 🏅 Conquistas do admin do treino; doc: PERFIL.md
/treino/admin/access-log?day=YYYY-MM-DD GET log de acessos (filtra por dia)
/treino/admin/queue GET/POST pendentes por lista + calibração {total_pending,spool_queued,calib_pending,calib_inflight,calib_targeted,lists:[{contest,name,pending}], routing, pending_details} (routing = roteamento do ESCRITOR com shards: {shards,workers:[{shard,alive_age_s,in_submit,in_results,in_other}],orphans,queue_depth,assigned,delivered_5m}alive_age_s:-1 = worker do shard nunca bateu; orphans = arquivos em s<j> com j>=K, mismatch de JUDGED_SHARDS entre API e daemon) (calib_pending = fila de calibração kind=calibrate, separada de index; calib_targeted = recalibrações direcionadas por host). &details=1pending_details:[{contest,login,problem,lang,id,since,age_s,state,has_source}] — CADA submissão pendente, com estado no pipeline (`no-spool
/treino/admin/judges GET máquinas de juiz (modelo pull) {online,busy,machines:[{host,online,busy,status,langs,cage_root,cache,tl,current,current_jobs,queued_calibrate,slots,partition,topology,config,report}]}current_jobs = TODOS os jobs em execução (multi-slot; UM por slot ocupado, com since = epoch do claim; current = o 1º, compat — a UI da fila itera current_jobs); status = auto-relato do agente novo (ok|draining|disabled, null = agente antigo) e busy-sem-job vira [{kind:"draining"|"disabled"|"unknown_busy"}]; slots:{free,total}; partition = vigente no agente; config = a config DESEJADA (judges-config) ou null; cache = pacotes em disco do juiz (não RAM); report.gpu = GPU de compute comprovada ({vendor:nvidia|amd,names}) ou null
/ops/judge-config GET ?host= / POST {host, partition?:off|numa|cpus:<X>, reserve?, disabled?} (admin) config fina POR JUIZ (multi-slot): particionamento da máquina em slots com pinning, cpus reservadas e desabilitar (drena). Vive em contests/treino/var/judges-config.json; o heartbeat entrega ao agente quando muda (cfg_hash) e o agente aplica após DRENAR os jobs em andamento. CLI: moj judges config
/ops/judge-reset POST {host, action?:kill|restart} (admin) RECUPERAÇÃO sem SSH: kill (default) manda o agente SIGKILL-ar o grupo de processos de cada slot (job inteiro), reportar judge-error/calib-fail (nada espera TTL) e reconciliar a config; restart = kill + o agente se re-executa (register boot:true re-enfileira o que estava atribuído — fila não se perde). Entregue no próximo heartbeat MESMO com o juiz ocupado/desabilitado. CLI: moj judges reset/restart
/ops/calib-cancel POST {id, inprogress?:false} (admin) cancela calibrações do problema na fila: remove pendentes + direcionadas não entregues → {removed_pending,removed_targeted,removed_inprogress,inflight}; as EM EXECUÇÃO só com inprogress:true (senão só contadas em inflight — prefira judge-reset). CLI: moj judges cancel
/ops/judge-results GET ?host=&limit= (admin) relatório de correções por juiz: últimas N correções (run/results/, com host/verdict/duração) + agregado by_host:{total,accepted,judge_errors,avg_duration,last_at}. CLI: moj judges results
/ops/judge-cache POST {host, action?:clearcache} (admin) limpa o cache local de pacotes de um juiz: enfileira um comando POR-HOST que o agente pega no próximo heartbeat (quando estiver livre), apaga o $JUDGE_CACHE e se re-registra com inventário vazio. Não bloqueia — devolve {action,host,cmdid,status:"queued"} e o efeito aparece no /judge/list. Use quando um juiz ficou com pacote velho/corrompido em cache
/treino/admin/stats GET {users,active_sessions,problems:{total,public,private},by_author:[{author,owner,total,public,private}],problems_public_by_day:[{day,count}],logins_per_day,submissions_per_day} — contagens da plataforma (privados contados, não listados); problems_public_by_day alimenta o mapa de calor de entrada de públicos (data aproximada; ver public_at)
/treino/admin/response-stats GET tempo de resposta + volume (cacheado): {coverage, overall, per_day, by_dow_hour, subs_per_day:[{day,count}], subs_by_dow_hour:[{dow,hour,n}]}. Tempo só de submissões com finalized_at; volume conta TODAS as linhas do history. EPOCH/UTC
/treino/admin/calib-activity GET volume de calibrações no tempo (cacheado; do log run/updates/log): {calib_per_day:[{day,count}],calib_by_dow_hour:[{dow,hour,n}],total}. run/ pode rotacionar → histórico parcial
/treino/admin/logout-user POST {login} ou {logins:[…]} → remove as sessões (um ou vários)
/treino/admin/lock-user POST {login} ou {logins:[…]}trava (troca a senha por aleatória) + desloga
/treino/admin/logout-ip POST {ip} → encerra todas as sessões daquele IP (IPv4/IPv6)

Gestão de problemas (Bearer)

O FORMATO do pacote (arquivos, .moj-meta.json, .moj-id), o que são ORGs e COLEÇÕES e o ciclo validar → calibrar → publicar estão em PACOTE.md (fonte única). Aqui ficam só as rotas. Roteiro de montar um pacote: mojtools/README.md.

Backend = repo git LOCAL por problema (MOJ_PROBLEMS_DIR/<org>/<prob>, o servidor commita direto via problem_commit; sem serviço externo), mas o autor só usa o login do MOJ (sem chave/git). Listagens leem o índice de donos contests/treino/var/problem-owners.json (gerado por mojtools/gen-problem-owners.sh; regen em background, TTL PROBLEM_OWNERS_TTL_MIN). O índice é a fonte única: todo problema tem owner (login). Problema sem dono (legado não-migrado) é ignorado no índice; /mine = owner==login (sem casamento difuso). Não há mais "legado".

Controle de acesso — garantido na API, NUNCA só na interface. A fronteira é a ORG: ver o source/pacote/soluções/calibração e editar/operar é p/ MEMBRO da org (require_problem_edit = org_is_member) — sem atalho de .admin. Ver o detalhe/statement (get/validation) é membro da org ou se o problema é público (require_problem_view). Membro da org VÊ TODOS os problemas dela, inclusive privados, em toda listagem/painel (decisão 2026-07-16); problema PRIVADO não é nem LISTADO p/ quem não é membro da org nem colaborador por-problema (as listagens pré-filtram em owners_emit), inclusive p/ .admin — provas em elaboração não podem vazar. Não-autorizado recebe 404 (não revela a existência). Helpers centrais em lib/problems.sh; moj-cli/curl batem na mesma API e não burlam.

Rota Método I/O
/problems/mine GET {problems:[{id,title,author,owner,collections,public,html,claimed}]}claimed=true se owner==login, senão "provável" (nome casa)
/problems/shared GET problemas compartilhados com o login: tudo que ele pode editar e não é delemembro da org OU colaborador por-problema (não dono)
/problems/public GET problemas públicos (no treino livre) — visão de gestão (dono/autor)
/problems/collection?name=<c> GET problemas da coleção (curso/diretório, ex.: obi-problems)
/problems/collections GET {collections:[{name,count,public,owner,mine,can_manage}]}coleções = TAGS curadas (do registro), com contagem visível. Coleção (agrupamento, m:n) ≠ ORG (acesso, 1:1 — ver /orgs/*)
/problems/collection GET ?name problemas de uma coleção (filtra pela tag collections)
/problems/get?id=<id> GET detalhe: índice + validation (relatório do portão) + statement_html_b64/tags + time_limits (EFETIVO) / time_limits_calibrated / tl_override. ⚠ O TL vem do pacote (tl_store_served, override aplicado), não do json servível: o json público só existe depois de publicar — em problema privado (o estado de quem está calibrando) o campo sumia e o editor caía num fallback que mostra o máximo CRU entre juízes — e edit/upload não reindexam, então mesmo público o número podia estar velho. O checksum vem materializado do índice, então não há hash de pacote por request. O índice inclui languages (whitelist de submissão do .moj-meta.json; [] = todas as padrão) — a gestão exibe no detalhe (badges + atalho p/ o widget do editor)
/problems/validation?id=<id> GET último relatório de validação {checks:[{name,ok,detail}],html_built,render_warnings,ok}
/problems/status GET painel dos problemas do login (dono+colaborador+membro da org; privado de org alheia não aparece — owners_visible): {total,counts:{validated,…,needs_recalibration,good_sol_no_tl,public_unvalidated,needs_review,errors},calibrating_ids,attention_ids,problems:[{id,title,owner,author,public,validated,calibrated,being_calibrated,stale,needs_recalibration,good_sol_no_tl,good_sol_missing_langs,public_unvalidated,error,needs_review,review_reasons,time_limits,time_limits_calibrated,tl_override,updated_at}]}. time_limits é o EFETIVO (com o TLOVERRIDE do conf aplicado — o override vem carimbado no índice de donos por gen-problem-owners.sh, então o Painel não abre pacote nenhum); time_limits_calibrated é o cru dos juízes e tl_override é o declarado ({} sem override). good_sol_no_tl = tem solução good sem TL (linguagem suportada que falhou em TODOS os juízes); needs_review = precisa revisão (erro / good sem TL / público não validado ou não calibrado). stale/needs_recalibration do checksum do índice (≤30 min); sem hash de pacote por request; TL/validação vêm dos sumários por-evento run/{tl,validation}-summary.json (upsert pelos escritores; sem varrer run/tl por request)
/problems/tl?id=<id> GET time limits ao vivo (recomputa o checksum agora) + stale/needs_recalibration exatos: {problem,checksum,time_limits,time_limits_calibrated,tl_override,calibrated_checksum,hosts,updated_at,calibrated_at,calibrated,being_calibrated,stale,needs_recalibration}. time_limits = o EFETIVO (o que o aluno vê e o juiz honra): com TLOVERRIDE no conf do pacote, override[lang] // override[default] // calibrado[lang]; time_limits_calibrated = o cru dos juízes; tl_override = o declarado no conf ({} sem override). being_calibrated = há calibração pendente/em execução p/ este problema AGORA (mesma varredura do painel) — distingue "TL vazio porque acabou de enfileirar (validate/calibrate)" de "calibrou e não obteve TL". Quando needs_recalibration, explica o PORQUÊ: reason (checksum velho→novo), changes = commits desde a calibração que tocaram os caminhos que afetam o TL (conf/tests-input/sols-good/scripts — o que o tl-checksum cobre; [{sha,at,author,subject}], ≤20) e changed_files (≤30). Acesso: membro da org ou público (require_problem_view; 404 senão). Versão não-admin do /ops/problemtl. Python é UMA linguagem: py (pypy3) — chaves py3/py2 legadas são fundidas em py nos time_limits servidos (o cru de hosts pode ainda trazê-las até recalibrar)
/problems/recalibrate-stale POST {} | {ids:[...]} recalibra em LOTE tudo que "precisa recalibrar" no painel do login (calibrado + checksum divergente — mesma conta do /problems/status); ids restringe (intersectado com o conjunto AUTORIZADO — a fronteira é owners_visible, nunca o input). Cada item via cal_request (idempotente + serializado por-problema no claim — lote é seguro). Resposta {count, queued:[{id,reqid}]}. Web: botão "⚙ Recalibrar todos (N)" no Painel; CLI: moj calibrate --all-stale
/problems/calib?id=<id> GET calibração por juiz (membro da org): {id,checksum,good_langs,missing_langs,tl_override,time_limits,time_limits_calibrated,hosts:[{host,tl,missing,at,log,reports,sols}]}. ⚠ hosts[].tl e sols[].tests[].tl são a MEDIÇÃO da calibração, nunca o override — o calibreitor roda com MOJ_CALIBRATING=1 justamente para medir de verdade; time_limits (efetivo) existe para o cartão poder dizer o julgamento usa outro número. missing_langs = linguagens good sem TL em nenhum host (solução good falhou em TODAS as máquinas); hosts[].missing = faltantes naquele juiz. sols = a calibração POR EXTENSO daquele juiz, estruturada p/ ferramentas externas: [{file,lang,category:good|pass|slow|wrong,verdict,tests:[{name,code,time,tl}]}] — o MESMO formato do vetor tests de uma submissão normal (nome do teste, código curto AC/WA/TLE/…, tempo em s, TL usado); [] = juiz ainda não reportou o vetor (mojtools/agente antigos — o log texto continua). tl_override = o TLOVERRIDE do conf do pacote (ver PACOTE.md), {} sem override. Sols .py2/.py3 legadas contam como py
/problems/calib-report?id=<id>&host=<host>&name=<name> GET o report.html rico (o do build-and-test) de UMA solução, como saiu da calibração NAQUELE juiz (run/calib/<id>/r/<host>/<name>.html). Os nomes válidos vêm de hosts[].reports do /problems/calib. Devolve HTML, não JSON. Acesso: require_problem_edit — dono/colaborador, sem atalho de .admin (é código de solução). CLI: moj calib-report
/problems/my-stats GET análise dos problemas do login (dono+colaborador) agregada em TODA a plataforma (treino + turmas; cache precomputado). {totals:{owned,with_activity,attempts,accepts,solvers},overall_verdicts:[{verdict,count}],overall_languages:[{lang,submissions,accepted}],most_popular:{id,title,attempts},problems:[{id,title,attempts,accepts,wrong,acceptance_rate,distinct_users,solvers,contests_count,verdicts,languages,first,last}]}. Só os problemas do login; sem logins, sem nomes de contests (só contests_count) — não vaza prova privada
/problems/judges GET o parque de juízes para a calibração DIRECIONADA do editor: {judges:[{host,cpu,arch,langs,cage_root,last_seen,online}]}, ordenado por online › cpu › host (online = heartbeat nos últimos 30 s). O editor agrupa por cpu para oferecer "1 por processador". Só exige login (é inventário de máquina, não conteúdo de problema). CLI: moj calibrate --judges
/problems/validate POST {id} portão de qualidade, NÃO publicação: valida (portão estático: HTML compila + seções ## Entrada/## Saída + exemplos pareados) + gera o índice + pede calibração a um juiz (que roda as good e reporta o TL). NÃO mexe no public — problema privado continua privado (publicar é /problems/set-public, que checa a trava da ORG). Relatório: /problems/validation. Só membro da org.
/problems/publish POST {id} DEPRECADO — alias de /problems/validate (o nome fazia parecer que validar publicava)
/problems/request-calibration POST {id, hosts?:[...]} enfileira calibração (juiz roda calibreitor.sh, gera tl.<host>). IDEMPOTENTE: se já existe calibração pendente/em execução p/ o id, devolve o reqid existente com status:"already_queued" (nunca duplica job — lição do incidente 2026-07-15); direcionada (hosts) dedupa por host os comandos ainda não entregues (hosts[].status)

Autoria (escrita keyless — git escondido, commit autorado pelo login via problem_commit)

Rota Método I/O
/problems/repos GET diretórios/orgs de que o login é membro {repos:[{repo,owner,collaborators,collections,mine}]}
/problems/repo-create POST {repo, collections?} cria o diretório (org no namespace do login; provisiona a org implícita lazy)
/problems/source?id=<id>[&tests=meta|full] GET source editável {editable,title,titles,statement_langs,translations,enunciado_md,enunciado_format,author,tags,conf_text,public,collections,languages,examples,tests,sols{good,slow,wrong,pass,upcoming},score,score_text,editorial_md,scripts,scripts_files,docs_files} (translations = {"<lang>":{title,enunciado_md,editorial_md?,notes?:{"<sample>":md}}} das traduções docs/enunciado.<lang>.md/solucao.<lang>.md/notes/<sample>.<lang>.md; titles = {"<lang>":título} do meta; statement_langs = ["pt",…]; PT segue nos campos de sempre — 2026-09-15) SÓ MEMBRO da org (require_problem_edit); não-autorizado recebe 404 (sem read-only, sem atalho de .admin). Cada examples[i] traz explanation (opcional); editorial_md = resolução só p/ setter; scripts = caminhos relativos de scripts/ (árvore do editor web); scripts_files = ROUND-TRIP da correção especial — [{path,content_b64,exec} | {path,symlink}] (base64 suporta binário; symlink cobre os drivers interativos scripts/<lang> -> c); score_text = tests/score cru (round-trip byte-fiel do moj push/clone); languages = ids de linguagem de submissão permitidos (.moj-meta.json, [] = todas); docs_files = ROUND-TRIP das IMAGENS de docs/[{name,content_b64}] (figuras do enunciado/notas; nomes simples com extensão de imagem). examples[i].explanation vem de docs/notes/<sample>.md (formato de autoria) ou do legado sample-notes.json. tests=meta (DEFAULT): os testes OCULTOS saem sem conteúdo — {name,size_in,size_out,omitted:true} — e a resposta traz tests_omitted:true; o conteúdo de um teste vem por /problems/test. Motivo: problema com testes grandes (OBI: inputs de 12 MB) gerava corpo de centenas de MB (52 s medidos) e o editor web ficava todo esse tempo com o formulário VAZIO, idêntico ao de "problema novo". tests=full devolve o conteúdo (é o que o moj clone usa — round-trip). Ao salvar, teste com {name, keep:true} preserva o conteúdo que está no servidor (o editor manda isso para os testes que não baixou)
/problems/preview POST {enunciado_md, enunciado_format?, examples?, title?, id?, images?, lang?} ou {kind:"editorial", markdown, id?, images?, lang?} pré-visualização HTML (= o renderizador único render-statement.sh, idêntico ao servido) — injeta o título (h1) e os exemplos (cada explanation renderizada em markdown com embed). lang (pt|en|es, default pt; 400 lang_invalid) = rótulos dos exemplos (Exemplos/Examples/Ejemplos…) e <html lang>; o cliente manda texto e explicações JÁ no idioma (o HTML dos exemplos sai do stmt_samples_html do mojtools, o MESMO do índice). kind:"editorial" renderiza só o markdown, sem exemplos e sem h1 — o botão Pré-visualizar da aba Resolução. Resposta {html_b64, lang, kind}. Imagens-arquivo aparecem: com id (exige require_problem_edit) as imagens de docs/ do pacote são semeadas no render; images:[{name,content_b64}] (≤16, nomes de imagem saneados) cobre figura ainda não enviada → {html_b64}
/problems/download?id=<id>[&sha=<sha>] GET baixa o pacote .tar.gz (inclui soluções → membro da org); com sha, a versão daquele commit (git archive, worktree intocado); stream binário
/problems/test?id=<id>&name=<teste> GET conteúdo de UM teste {name,input,output} — o par do tests=meta; mesmo gate do source (só membro da org; 404 p/ os demais)
/problems/test-run POST {id, filename, code_b64} roda UMA solução avulsa NO JUIZ (autoria): job real na fila (banda lista-privada, contest sentinela _testrun), mesma jaula e mesmo TL da submissão de aluno, sem tocar history/placar de ninguém{run:<32hex>, status:"queued"}. Gate: membro da org (require_problem_edit, 404 — rodar contra os testes ocultos revela o problema). Teto SUBMIT_MAX_KB (413); linguagens aceitas = PLATAFORMA ∪ languages do pacote (a whitelist de SUBMISSÃO do problema não vale aqui — autor testa o que quiser que rode); rate: máx 3 runs queued por login (429 testrun_busy); auditado (test-run). Registro em run/testrun/ com TTL de 7 dias (GC preguiçoso)
/problems/test-run?run=<32hex> GET polling do test-run: {run,problem_id,filename,lang,status:queued|done,requested_at} e, quando done, +{verdict,verdict_canon,score,correct,total_tests,duration_s,tl_used,tests:[{name,code,time,tl}],finished_at,report:bool} — o vetor tests é o MESMO da submissão normal. Gate pelo problema DO REGISTRO (membro da org; 404)
/problems/test-run-report?run=<32hex> GET o report.html do test-run (HTML; 404 enquanto julga/expirado). Mesmo gate do registro
/problems/history?id=<id>[&limit=N][&sha=<sha>] GET histórico git do problema (membro da org — expõe soluções/testes). Sem sha: {id,commits:[{sha,at,author,subject,files,insertions,deletions}]} (limit≤200, default 50). Com sha: o git show -p{sha,at,author,subject,truncated,diff_b64} (diff limitado a 400 KB)
/problems/restore POST {id, sha, confirm} restaura o problema ao estado do commit sha como um COMMIT NOVO (história nunca é reescrita; confirm repete o sha). O .moj-meta.json (público/coleções/owner) é PRESERVADO — meta antigo não republicaria prova privada. Sem revalidação/recalibração automática (igual ao edit). Membro da org
/problems/upload POST {id|repo,prob, tar_b64} sobe um pacote (.tar/.tar.gz/.tar.bz2/.tar.zst/.zip) e substitui o conteúdo (commit). Do .moj-meta.json do tar lê os campos de CONTEÚDO — display_title, collections, languages (ausente/[] ⇒ preserva); os de ACESSO (public/public_at/owner) nunca vêm do tar. Tar sem o arquivo tags ⇒ preserva as do servidor (curadoria); com (mesmo vazio) ⇒ substitui
/problems/export?id=<id> GET baixa o problema como pacote ICPC/Kattis (2025-09) .tar.gz (problem.yaml+statement+data+submissions); inclui soluções → exige escrita/admin (mojtools/kattis/export.sh)
/problems/import POST {repo, prob?, tar_b64} importa um pacote ICPC/Kattis (mojtools/kattis/import.sh) → cria um problema MOJ julgável (checker custom via bridge); exige permissão de criação. Round-trip sem perda via .kattis.json
/problems/create POST {repo,prob,enunciado_md?,author?,tags?,examples?,good_sol?,title?,collections?,languages?,...} cria problema novo; commit+push; {id,sha}. prob = slug minúsculo ^[a-z0-9][a-z0-9._-]{1,80}$ (400 prob_invalid); collections tem de EXISTIR no registro curado (400 coll_unknown — MESMA trava do edit/set-collections; a homônima da org é isenta). languages = ids permitidos de submissão ([]/ausente = todas)
/problems/edit POST {id, ...campos} edita (só campos presentes); commit+push autorado. Aceita translations ({"<lang>": {title?, enunciado_md?, editorial_md?, notes?:{"<sample>":md}} | null} — idioma ausente = intocado; null = apaga enunciado/editorial/notas/título do idioma; dentro do idioma, campo ausente = intocado e "" = apaga; notes presente SUBSTITUI as notas daquele idioma; só en/es) e titles ({"<lang>":título}, mesclado no meta; o servidor só guarda idioma com arquivo). Salvar as examples[].explanation PT nunca apaga nota traduzida. Aceita languages (ids de submissão permitidos no .moj-meta.json; ausente = não toca, [] = limpa/todas). Aceita também scripts_files (SUBSTITUI scripts/ inteiro quando presente — paths validados, sem .., confinado a scripts/, exec vira +x, symlink recriado se o alvo resolvido fica dentro de scripts/; campo ausente = não toca) e score_text (grava tests/score verbatim; "" remove). Aceita docs_files (SUBSTITUI as imagens de docs/ quando presente — nomes saneados, só extensão de imagem, cap ~3MB; ausente = não toca). examples[].explanation grava docs/notes/<sampleN>.md (1 markdown por exemplo — e REMOVE o legado sample-notes.json). Mexer em scripts/ muda o tl-checksum ⇒ recalibração. O editor web gere a correção especial na sub-aba "⚙ correção" (Soluções & Correção — lista + templates) e envia scripts_files no save
/problems/script-templates GET templates de corretor especial (lidos de mojtools/script-templates/<key>/ — criar template = criar uma pasta lá): {templates:[{key,name,description,conf_hints,files:[{path,content_b64,exec} | {path,symlink}]}]}files no MESMO shape do scripts_files (aplicar = preencher a seção da UI e salvar). Symlink externo do template (drivers canônicos do mojtools) vem RESOLVIDO como conteúdo; symlink interno (cpp -> c) vem como symlink. Iniciais: checker-testlib, interativo, interativo-rank, compare-float, ban-funcoes-c
/problems/delete POST {id, confirm} REMOVE o problema (git rm da subpasta + push) e do treino. Destrutivo: confirm tem de repetir EXATAMENTE o id. Dono/colaborador ou admin
/problems/set-public POST {id, public:bool} público on => valida + calibra (index_problem_bg no servidor; só entra no treino se o portão passar) e grava public no .moj-meta.json; off => sai do treino na hora. A calibração só entra na fila se o pacote MUDOU desde a última calibrada (tl-checksum atual ≠ checksum do store servido) — resposta traz calibration:"queued"|"up_to_date"; publicar em massa sem mudança não enfileira recalibração redundante
/problems/set-collections POST {id, collections:[...]} define as coleções (tags) do problema no .moj-meta.json; valida contra o registro (curada: a coleção tem de existir)
/problems/move POST {id, to_org} move um problema de rascunho p/ outra org (muda o id <org>#<prob>); bloqueia se público/em uso (senão órfãoria o histórico); exige ser membro das DUAS orgs
/problems/repo-collaborators GET ?repo / POST {repo,add?,remove?} compartilha o diretório (membro da org; só o dono gerencia). Cada login em add precisa existir no treino e poder criar problemas (cc_can_create) — senão 422 login_invalid/404 user_notfound/403 cannot_create, recusa ATÔMICA; remove não valida
/problems/collection-create POST {name} cria uma coleção (TAG) no registro curado. Nome é TEXTO LIVRE (pode ter espaços/acentos — é só rótulo). Exige permissão de criação; criador = dono. (NÃO é org: acesso é por org)
/problems/collection-rename POST {name, to} renomeia a coleção: registro NA HORA + re-tag dos N problemas em BACKGROUND (retag:"background", devolve retag_job p/ acompanhar; síncrono estourava o timeout do nginx). RETOMADA: name inexistente + to existente = bulk anterior morreu ⇒ repete só o retag (resumed:true). Só dono ou .admin
/problems/collection-delete POST {name} exclui a coleção: untag dos N problemas em BACKGROUND (devolve retag_job) e o registro só sai NO FIM (untag:"background"; morreu no meio ⇒ a coleção ainda existe, repetir o delete RETOMA). Só dono ou .admin
/problems/collection-retag-status?[job=<id>][&name=<coleção>] GET situação dos jobs de retag (rename/delete): {jobs:[{id,from,to,by,started_at,total?,done,failed,finished_at?}]} mais novos primeiro (últimos ~50); sem finished_at = rodando (done/total = progresso, total é estimativa). job= filtra pelo id devolvido em retag_job; name= por from/to

source/create/edit cobrem o pacote inteiro: title (vem do campo, não de % Título no texto — o render injeta o h1), enunciado_md, conf_text (TL/ulimits/STOPWHEN/…, ver saad-problems/README.org), examples (sample; cada um aceita explanation opcional → docs/sample-notes.json, mostrada após o exemplo), tests (ocultos), sols por categoria {good,wrong,slow,pass,upcoming} (cada [{filename,code}]), score (grupos de pontuação; cada grupo tem {name,weight,glob} e o glob pode ser uma lista ", "-separada de padrões, ex.: g2_*, g3_*) e editorial_md (resolução em markdown → docs/solucao.md, só p/ setter, não vai ao aluno).

Quem pode criar (problemas/pastas/coleções) = mesma regra de criar contest (cc_can_create: .admin ou allowlist ou ≥ N resolvidos, menos a denylist) — gerida em /treino/admin/contest-perms. create/repo-create/collection-create/upload-novo exigem isso; editar/compartilhar problema existente continua por colaborador (org_is_member).

Orgs (modelo MOJ-nativo)

Conceito completo (ORG = acesso, COLEÇÃO = agrupamento, e por que são ortogonais): PACOTE.md.

Storage = repo git local por problema (MOJ_PROBLEMS_DIR/<org>/<prob>), e o acesso é por ORG (o <org> do id <org>#<prob>): quem é membro escreve em qualquer problema da org; a org tem uma trava de público (public_allowed, privada por PADRÃO → problemas nunca ficam públicos: anti-vazamento de prova), e só admin da org a muda. Cada usuário tem uma org implícita <login> (sempre privada). Registro: contests/treino/var/orgs.json (lib/orgs.sh).

Rota Método Descrição
/orgs/list GET orgs de que o login é membro (inclui a implícita, criada aqui): {orgs:[{name,title,members,admins,public_allowed,implicit,count,public,mine,can_manage}]}. Não lista org alheia
/orgs/get GET ?name detalhe de 1 org; só membro/admin ou .admin global, senão 404 (não vaza existência)
/orgs/create POST {name,members?,admins?,title?,public_allowed?} cria org; o criador vira membro+admin (exige cc_can_create, a regra de criar contest). Cada login de members/admins precisa existir no treino e poder criar problemas — 422/404/403 senão (recusa ATÔMICA: a org nem nasce)
/orgs/members GET ?name / POST {name,add?,remove?,admins_add?,admins_remove?} só admin da org (ou .admin) gerencia; criador blindado; org implícita não tem gestão. add/admins_add validam cada login (existe no treino + cc_can_create; 422 login_invalid/404 user_notfound/403 cannot_create, atômico); remove/admins_remove não validam (lixo já gravado precisa poder sair)
/orgs/set-public-allowed POST {name,public_allowed:bool} liga/desliga a trava (só admin da org; implícita ⇒ 409). Desligar DESPUBLICA em cascata os problemas públicos da org (tira do treino) — resposta traz unpublished
/orgs/delete POST {name} remove uma org VAZIA (sem problemas — conferido em disco); só admin da org (ou .admin); org implícita409 implicit_org; org com problema ⇒ 409 org_not_empty

O CLI moj (web/moj, servido em GET /moj; fonte em moj-cli/) usa essas rotas para autoria sem git/sem chave: moj new/clone/push/publish/share/org/mv. Storage MOJ-nativo: o servidor commita no repo git LOCAL de cada problema (MOJ_PROBLEMS_DIR/<org>/<prob>).

Permissão de escrita = membro da ORG do problema (org_is_member; sem atalho de .admin). Visibilidade imediata via overlay contests/treino/var/authored.json (mesclado ao índice). Público só se a org permitir (public_allowed) — camada anti-vazamento de prova.

Submissão (assíncrona)

Rota Método Auth I/O
/contest/beacon?contest=<c> GET Bearer {beacon,server_utc}. Beacon de tempo p/ a submissão offline (moj-comp): payload_b64.sig_b64, payload {v,c,l,t,n} assinado RSA-PSS com a chave do contest. A CLI re-ancora a cada comando com rede; o beacon embutido no pacote offline prova que ele nasceu depois de .t (piso do carimbo). Ver lib/contest-offline.sh e FLOW.md §offline.
/contest/offline-submit?contest=<c> POST Bearer body {packets:["<pkt-json>",…]} (máx 50). Rota emergencial do moj-comp: pacotes cifrados (RSA-OAEP+AES-256-CBC com sha do conteúdo no envelope) criados SEM rede. Valida por pacote: decripta; v/login/contest conferem; beacon assinado do mesmo login/contest; beacon.t ≤ claimed_utc ≤ now+30s; claimed na janela DO aluno (start…fim efetivo, extend conta); claimed monotônico vs último aceito; dedup por sha256; extensão na whitelist de linguagens do problema (mesma regra do /submit; fora dela = pacote rejected na chegada). Aceito ⇒ spool com time=claimed (contabiliza no horário reivindicado — placar/penalidade usam sub_epoch) + var/offline-log + audit (offline-submit, com gaps beacon→claimed→chegada p/ o organizador adjudicar). → {results:[{sha,status:accepted|rejected|duplicate,…}],accepted,rejected}
/submit?contest=<c> POST Bearer body {problem_id,filename,code_b64,source?} (source=web|file) → {submission_id,status:"queued"} (não bloqueia). A linguagem é a extensão, CANONICALIZADA na porta (lang_canon_ext, lib/langs.sh): C++ = .cpp, .cc, .cxx, .c++ (e .hpp) → CPP; .hC; .py2/.py3PY. É o canônico que vai ao spool (lang), ao history, ao archive (submissions/<id>.cpp) e ao juiz — antes ia a extensão crua (CC) e o julgador morria em "Language 'cc' not availale" (2026-09-14). O filename mantém a extensão original. O filename é NORMALIZADO pelo servidor (safe_src_filename): o cliente manda o que quiser, o juiz recebe um nome sadio — sai o caminho, saem espaços, sai o (N) que o navegador gruda em download repetido (l(1).cppl.cpp) e saem os metacaracteres de shell/make; acento é preservado (em Java o arquivo tem de casar a classe pública). Sem isso o mesmo código dava AC como l.cpp e Compilation Error como l(1).cpp — o nome chega cru ao recipe do make, que o entrega ao /bin/sh (relato de time, 2026-08-24). Vale igual no /contest/offline-submit e no /problems/test-run. Teto de fonte SUBMIT_MAX_KB (1024) → 413 source_too_large; whitelist com CHÃO: lista de linguagens vazia = as da PLATAFORMA (PLATFORM_LANGS, as 17 de mojtools/lang/), nunca "qualquer extensão" (.exe → 400 lang_not_allowed); e o submit é fail-closed: o spool é validado ANTES do OK (falha → 500 spool_write_failed, sem linha pendente no history) — as três correções do incidente 2026-08-19. Registra o editor em var/editor-log p/ o card "editor da semana". Gate por fase+papel (forçado pela API): .admin/.judge submetem sempre; .staff nunca (403 submit_forbidden); usuário normal e .mondurante a janela (403 contest_not_started antes do início, 403 contest_ended após o fim) — o .mon submete mas fica fora do placar. Whitelist de linguagens FORÇADA (400 lang_not_allowed): a extensão do filename (canonicalizada: py3→py, cc/cxx→cpp…) tem de estar na lista efetiva do problema — override do contest (problem-langs.json) → LANGUAGES do conf → languages do pacote → todas (fonte única lib/langs.sh, a MESMA da listagem /contest/problems). Campo opcional virtual:"<cid>" (só com contest=treino; docs/VIRTUAL.md): etiqueta a submissão como parte da participação virtual do login — mesmo portão das rotas do virtual (404 virtual_unavailable), problema tem de ser da prova (400 virtual_problem), run tem de estar rodando (403 virtual_not_running) e a whitelist de linguagem passa a ser a do CONTEST. No TREINO o problema tem de ser VISÍVEL ao login (404 problem_notfound): público no índice (var/jsons/), ou privado de que ele é dono/colaborador/membro da org; privado alheio e id inexistente respondem IGUAL (a resposta não confirma existência) e nada entra na fila; índice de donos indisponível = recusa (fail-closed). Antes, um id privado conhecido era julgado p/ qualquer conta (2026-09-18). Em contest o conjunto é o do conf — nada muda.
/submission/source?contest=<c>&id=<subid> GET Bearer código-fonte (texto). Só o DONO da submissão e juiz/admin (403 source_forbidden). O mesmo corte vale p/ /submission/log (403 log_forbidden) e /submission/summary (id alheio é omitido). A opção SHOWCODE/show_code, que abria fonte, report e resumo de TODOS a qualquer login do contest, foi removida em 2026-09-18: linha SHOWCODE em conf antigo é morta
/submission/log?contest=<c>&id=<subid> GET Bearer log do julgamento (report.html; expõe input+diff de TODOS os testes). Em repouso é mojlog/<id>.html.gz (2026-09-16): com Accept-Encoding: gzip sai com Content-Encoding: gzip, senão descomprimido; report apagado pela retenção com results/<id>.json presente = nota bilíngue "removido pela política de retenção". Juiz/admin sempre; dono conforme o SHOWLOG efetivo (showlog_effective em lib/verdict.sh): SHOWLOG explícito no conf manda; ausente = OCULTO em modo icpc (anti-vazamento de prova) e visível nos demais modos
/submission/summary?contest=<c>&ids=<csv> GET Bearer resumo ESTRUTURADO em lote (p/ a linha de detalhe sob o veredicto canônico), de results/<id>.json: { "<id>":{verdict,verdict_canon,score,score_max,score_kind,correct,total,groups,heur_score?,heur_adjusted?} } (veredicto manual com texto p/ o time: nos níveis score/none verdict = o TEXTO do time e verdict_canon = a classe; o daemon grava verdict_team no results). REDIGIDO pelo modo do contest (lib/verdict.sh): full (treino/lista) = tudo; score (obi/heurístico/outro) = canônico + score/groups/heur sem correct/total; none (icpc/ausente) = só o canônico (anti-leak: nem o dono recebe score) — nos níveis redigidos verdict = canônico. Juiz/admin: sempre full com verdict cru. Mesmo gate do log (dono/admin/juiz; respeita o SHOWLOG efetivo — explícito manda, ausente = oculto em modo icpc); ids de terceiros são omitidos (não 403). score_kindtests|points; groups = [{earned,max},…] na ordem do tests/score (earned null = grupo não executado). Até 1000 ids; results antigos: verdict_canon derivado da string e groups da cauda legada Pontos | … | (com max:null)

Contest

Rota Auth I/O
/contest/basic?contest=<c> {contest_id,contest_name,start_time,end_time,login_start_time,locale,login_enabled,freeze_time,score_anon,languages[],secret,modules[]} (modules = ids dos MÓDULOS ligados do contest — CONTEST_MODULES do conf, catálogo em lib/modules.sh; é UX: decide quais grupos/painéis o admin e o chefe mostram, o acesso continua cortado em cada rota; languages = whitelist do conf LANGUAGES=; [] = todas; locale = pt/en explícito impõe o idioma da interface do contest, "" = não setado ⇒ o front cai no seletor/idioma do browser; round = {slug,name,kind,warmup} da rodada ATIVA ou null — é o que faz o front avisar em faixa fixa que aquilo é AQUECIMENTO; cohort = {id,name,unranked,public,view,released,views[]} da coorte do login (só com sessão; null sem coortes) — o front avisa o convidado e mostra o seletor Oficial × Geral; score_views = [{id,name}] das coortes PÚBLICAS com placar próprio (ranking:true — ex.: times × individual), lista pública que vira o seletor do placar). Continua público mesmo em contest secreto — a tela de login/countdown precisa do nome p/ quem tem o link. Com Bearer de sessão deste contest (opcional), end_time é o fim EFETIVO do login (prorrogação por sede/grupo via time-overrides.json — o countdown mostra o certo) Inclui balloon_style (icon|fill, default icon) = como o placar pinta a célula resolvida — icon: fundo neutro + ponto da cor (a cor deixa de ser o único sinal de "resolvido"; o balão BRANCO da paleta padrão dava 1,00:1 contra o fundo e sumia) · fill: cor do balão no fundo + contorno derivado. Vale p/ placar, cerimônia e relatório; ver SCOREBOARD.md. Inclui penalty_minutes (regra de pontuação, não segredo — a cerimônia de revelação precisa dela p/ recalcular penalidade e ORDEM; antes só existia no /contest/admin/settings, admin-only, e o telão caía no default 20). Resposta em CACHE por VARIANTE (`var/basic-cache.<anon
/contest/userinfo?contest=<c> Bearer {login,name, …team/país/univ/show_log opcionais}
/contest/navbuttons?contest=<c> Bearer botões por papel (SEM emoji desde 2026-09-15, issue #28; o competidor ganha Minhas submissões/contest/submissions/, issue #26; .admin/.judge/.staff/.cstaff — o .cstaff ganha Etiquetas e, quando o contest terminou p/ todas as sedes, 🏆 Revelação; o .staff não tem mais Etiquetas; .staff/.cstaff ganham 📄 Documentos) Resposta em CACHE por PAPEL (`var/nav-cache.<animeitor
/contest/problems?contest=<c> Bearer {problems:[{short_name,full_name,problem_id,has_statement_html,has_statement_pdf,statement_langs,time_limits,languages,author?}], statement_langs, default_statement_lang} (statement_langs do envelope = os idiomas que a prova OFERECE (STATEMENT_LANGS do conf, admin/chefe em /contest/admin/statement-langs; ausente = AUTOMÁTICO: todo idioma que cada problema tem, e o envelope lista a união do que existe, pt sempre); default_statement_lang = o LOCALE do contest se oferecido, senão o 1º — é onde a sanfona abre; statement_langs de cada problema = oferecidos ∩ com arquivo enunciados/<skey>[.<lang>].html|pdf ou tradução no banco, materializada aqui na 1ª vez como o PT — a sanfona mostra chips só quando >1) (author = crédito de quem escreveu, do arquivo author do pacote; só sai depois do fim para todas as sedes — ou p/ admin/juiz-chefe/juiz —, porque durante a prova o nome do autor é pista) (problem_id = forma canônica coleção#problema, igual ao treino — é o que o juiz usa p/ achar o pacote; time_limits = {lang:seg} do store, {} se o conf ocultar via SHOWTL=0 — com pool de juízes definido (override do problema em problem-judges.jsonCONTEST_JUDGES do conf) o MAX é só entre os hosts do pool efetivo; languages = ids permitidos do problema: override por problema (problem-langs.json) → whitelist do contest (LANGUAGES) → default do próprio pacote (.moj-meta.json languages, servido no índice do treino) → [] (=todas) — o último elo faz um problema "só-pddl" restringir sozinho sem o contest configurar nada; com restrição, o front mostra um chip de TL por linguagem permitida — o TL medido dela ou o default — e omite o chip "padrão"; sem restrição, chips medidos + "padrão"). Gate de visibilidade (forçado pela API): .admin/.judge veem sempre; .staff nunca; usuário normal só após o início — antes disso retorna {problems:[], locked:"not_started"} (.stafflocked:"staff"), e o front mostra a tela de contagem regressiva. Resposta em CACHE (`var/problems-cache.<author
/contest/samples?contest=<c>&problem=<letra|problem_id> Bearer os exemplos do enunciado como dado `{problem,problem_id,samples:[{name,input,output}
/contest/statement?contest=<c>&problem=<letra|problem_id>&format=html|pdf&lang=pt|en|es Bearer UM enunciado, cru (text/html ou application/pdf; format default html). lang (default = default_statement_lang do contest): fora da allowlist → 400 lang_invalid; idioma que a prova NÃO oferece → 404 (como problema inexistente); oferecido sem arquivo → serve o PT (tradução ausente nunca é erro). Arquivo = enunciados/<skey>.<lang>.<fmt><skey>.<fmt>; ETag pelo arquivo resolvido; X-MOJ-Statement-Lang = idioma do arquivo servido. Gate IDÊNTICO ao da lista (can_see_problems): .staff/.cstaff nunca, competidor só depois do início, admin/juiz sempre — e a recusa é 404, não 403 (pedir o enunciado direto não pode confirmar que o problema existe antes de a prova abrir). A chave do arquivo sai sempre do PROBS do conf, nunca do parâmetro (problem=../x = 404). Responde ETag (mtime+tamanho) + Cache-Control: private, max-age=60 e honra If-None-Match com 304 — recarregar a página não repuxa MB, e enunciado corrigido no meio da prova invalida sozinho.
/contest/news · /contest/resources Bearer seções opcionais (vazias = ocultar). Notícia pode ter anexo {file:{name,size}}
/contest/news-file?contest=<c>&id=<news_id> GET Bearer
/contest/backup?contest=<c> GET/POST Bearer
/contest/backup-file?contest=<c>&id=<id>[&login=<l>] GET Bearer
/contest/print?contest=<c> GET/POST Bearer
/contest/print-file?contest=<c>&id=<id> GET Bearer
/contest/staff/queue?contest=<c> GET Bearer (.staff/.cstaff/admin)
/contest/staff/print-action?contest=<c> POST Bearer (.staff/admin; .cstaff não — 403)
/contest/staff/print-pdf?contest=<c>&id=<id> GET Bearer (.staff/admin; .cstaff não — 403)
/contest/badges?contest=<c>[&staff=<l>&include_disabled=1] GET Bearer (.cstaff/admin; .staff403 cstaff_required)
/contest/doc?contest=<c>[&type=<info-sheet|contest|times>&lang=<pt|en|es>&fmt=<pdf\ **Tipos**: info-sheet|contest|times|editorial. **Gate de FASE** (além do de publicação): p/ quem NÃO julga (organização = só admin/chief/judge; **.staff/.cstaff/.monesperam a fase como o time** — decisão de 2026-09-15: a sede não recebe o caderno antes da prova),contest/times publicados só aparecem/baixam **a partir do início** (contest_phase != before) e editorial só **depois do fim p/ TODAS as sedes** (contest_over_for_all— prorrogação segura);info-sheet` = publicado é visível. A LISTAGEM filtra igual (o time nem vê a linha). html>]` GET
/contest/rounds?contest=<c> GET Bearer
/contest/round?contest=<c>&round=<slug>[&file=index.html] GET Bearer
/contest/updates?contest=<c>&news_since=&clar_since= Bearer resumo leve p/ polling de notificações: {news:{last,count,unread}, clar:{last,count,unread}} (clar = respondidas visíveis ao usuário; unread = date/answered_at > since)
/contest/history?contest=<c> Bearer TXT (submissões do usuário). O veredicto (campo 5) sai SEMPRE canônico (Accepted/Wrong Answer/… — lib/verdict.sh; veredicto manual com texto p/ o time sai como o TEXTO — canon_team), em todos os modos: a string de display com score/grupos fica no disco e o detalhe por modo vem do /submission/summary (redigido). Pendentes e strings desconhecidas passam intactos; o sufixo (Ignored) é preservado. O history em disco não muda
/contest/balloons?contest=<c> Bearer mapa letra/short→cor (default ICPC A–O) Resposta em CACHE (var/balloons-cache.json, sem variante — o mapa é o mesmo p/ todos). Sem teto de idade: as entradas cobrem 100% do corpo (balloons.json + a paleta padrão, que é código — o próprio handler entra como entrada, então um deploy invalida).
/contest/regions?contest=<c> Bearer regiões p/ filtro do placar (o filtro casa por nome — igualdade com a sede .team.region do time via /contest/teamsou pelo regex no login)
/contest/teams-meta?contest=<c> regras regex→{country,school,school_full} {rules:[…]} — placar resolve bandeira/escola e filtra por país/escola (bandeiras locais em /shared/flags/). Fallback: só preenche o que o por-usuário (/contest/teams) não trouxe
/contest/teams?contest=<c> — (secreto exige sessão) ⚠️ com coortes, só os logins das coortes que o chamador pode ver (é o endpoint PÚBLICO que mais vazaria um convidado). diretório de TIMES por-usuário p/ o placar mesclar: {teams:{<login>:{univ_short?,univ_full?,flag?,region?,has_logo,has_photo}}} (o NOME vem do TXT do placar — fullname) — do .team do account.json + presença de logo.png/photo.png; só logins não-privilegiados com algo a dizer. Precedência no placar: isto > teams-meta (regex) > vazio
/contest/team-photo?contest=<c>&user=<l>[&thumb=1] foto do time (thumb=1 = miniatura de 320px, ~7 KB, com cache longo — é o que a galeria do .animeitor usa). ⚠ Time sem foto NÃO dá mais 404: devolve a foto padrão do contest (200) com o cabeçalho X-MOJ-Photo: placeholder — é o que faz o Animeitor achar imagem para todo time. Quem precisa saber quem MANDOU foto usa o has_photo das listagens (lado máx 1000) — o placar não mostra isso (2026-08-24: "deixar simples"); quem cobra quem não mandou é a galeria do telão e o painel Pessoas › Times. Serve image/webp (formato de hoje) ou image/png (acervo antigo — ver lib/team-photo.sh). 404 só quando nem a padrão existe. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/team-music?contest=<c>&user=<l> música do time (audio/mpeg + Content-Length): a faixa que o telão toca quando ele resolve. Mesma doutrina da foto — time sem música NÃO dá 404: devolve a música padrão com X-MOJ-Music: placeholder; quem precisa saber quem MANDOU usa o has_music das listagens. Guardada como veio (mp3 validado por MIME, sem conversão — não há ffmpeg na imagem). Sem Range: o player toca progressivo. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/placeholder?contest=<c>[&kind=photo|music][&thumb=1] o padrão do contest — o que a API responde no lugar do asset de quem não mandou o seu: kind=photo (default) = a foto, kind=music = a música. Escolhido pelo .animeitor; sem escolha, o de fábrica (server/etc/team-placeholder.webp / .mp3). Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/team-logo?contest=<c>&user=<l> PNG do brasão do time (máx 128; célula do time no placar — vence o logo por regra do teams-meta). 404 sem brasão. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/webcast?contest=<c>&key=<K> — (só a chave) ZIP do placar no protocolo do Animeitor (o mesmo do webcast.php do BOCA: contest/runs/time/version/icpc, campos separados por 0x1C). Rota SEM SESSÃO, de propósito: é o sistema Animeitor buscando em loop. A chave (criada pelo .animeitor) declara a visão de coorte servida; chave inválida/revogada → 404 (e linha em var/webcast-denied.log). O pacote vai SEMPRE descongelado — quem anima a virada é o Animeitor, que sabe a hora do freeze pelo lastmilescore. Cache com piso de 10 s. Formato inteiro em docs/WEBCAST.md
/contest/score?contest=<c> (Bearer opcional) TXT (1ª linha = modo, que pode trazer flags: icpc s = célula resolvida em SEGUNDOS desde o início, exibida pelos clientes como floor(seg/60); sem a flag = minutos, legado) — ver SCOREBOARD.md. Pré-início (regra: o placar nunca revela a quantidade de problemas antes de a competição começar): antes do CONTEST_START, quem não é is_judge recebe a vitrine (var/placar-prestart.txt — os times da visão pública com bandeira/sigla/nome e zero colunas de problema; build.sh <c> --prestart). Cache preguiçoso: (re)gera placar.txt (público, com freeze) e placar-full.txt (completo, sem freeze) se history/conf mudou. Privilegiados (.admin/.judge/.cjudge + allowlist SCORE_FULL_USERS — vale p/ liberar um .cstaff) com token recebem o completo; demais, o público. &view=<visão> escolhe a visão de coorte: public força a pública/congelada mesmo p/ privilegiado, oficial = só as coortes públicas, geral = tudo (só vale p/ quem já pode ver tudo) e o id de uma coorte pública com ranking (ex.: individual, times) devolve o placar paralelo dela — é público, não exige sessão, e a página /contest/score/?c=<c>&view=<id> abre direto nele. view=public — é o que a cerimônia de revelação (/contest/score/reveal.html, estilo ICPC resolver, nativa) usa p/ computar o delta frozen→full e revelar de baixo p/ cima; o botão "Descongelar tudo" da cerimônia = settings freeze:0 (só admin). &scope=mine (honrado só p/ .cstaff) recorta o TXT servido (frozen e full) aos usuários que o chefe de sede enxerga (staff-filters) — é a cerimônia por sede; fora da allowlist, o full só sai p/ o .cstaff quando o contest terminou para todas as sedes (contest_over_for_all: fim do conf + o maior end de time-overrides.json — sede prorrogada segura a revelação). Em contest SUPER SECRETO (conf SECRET=1) o placar deixa de ser público: sem sessão daquele contest → 401 secret_login_required (idem balloons/regions/teams-meta).

Conta de placar / telão (.animeitor, o admin do contest — e a SEDE, recortada)

A sede entra recortada pelo staff-filters.json (o mesmo da fila/etiquetas/cerimônia): a listagem vem só com os times dela (scoped:true). O .cstaff usa photos, photo, music, photos-zip e o GET de placeholder — escrever em time de fora dá 403 staff_scope. O .staff é somente leitura: só photos e o GET de placeholder (photo/music/photos-zip → 403). Nenhum dos dois troca o padrão (POST 403) nem vê as chaves do webcast (403). | Rota | Método | I/O | |---|---|---| | /contest/animeitor/photos?contest=<c> | GET | galeria: {teams:[{login,name,univ,cohort,region,flag,has_photo,format,bytes,mtime,has_music,music_bytes,music_mtime}], total, with_photo, with_music, scoped, placeholder:{custom,mtime,music_custom,music_mtime}} (conta de papel fora). UMA varredura (find -printf + find\|xargs jq) para foto e música — com 1000 times são 0,1 s; um jq por conta levava 5,3 s. Para a sede (.cstaff/.staff) a lista vem recortada nela (scoped:true; +0,05 s da 2ª varredura do staff_visible_logins) | | /contest/animeitor/photo?contest=<c> | POST | {login\|filename, file_b64} sobe/troca a foto (convertida p/ webp, máx ~8 MB) · {action:"delete", login} remove. login aceita NOME DE ARQUIVO (fulano.jpgfulano), que é como o envio em lote funciona. Auditado (animeitor-photo); toca .score-dirty. .cstaff só na própria sede (403 staff_scope). ⚠ diferente do admin/team-assets, não recusa contest com USERS_FROM (a foto é asset local) | | /contest/animeitor/music?contest=<c> | POST | {login\|filename, file_b64} sobe/troca a música do time · {action:"delete", login} remove. MP3 validado pelo MIME (file --mime-type = audio/mpeg; extensão não basta) → 400 music_bad; máx 15 MB (413 file_large). Corpo lido em ARQUIVO (read_body_file). .cstaff só na própria sede (403 staff_scope). login aceita NOME DE ARQUIVO (fulano.mp3fulano), que é como o envio em lote funciona. Auditado (animeitor-music) | | /contest/animeitor/photos-zip?contest=<c> | GET | ZIP do telão: fotos/<login>.webp para todos os times (quem não mandou foto leva a padrão) + musicas/<login>.mp3 só de quem mandou + placeholder.webp e placeholder.mp3 na raiz + teams.csv (login,nome,universidade,coorte,bandeira,foto,padrao,musica,musica_padraopadrao/musica_padrao true = está com o padrão). A música padrão não é copiada por time: 5 MB × 1000 times viraria um pacote de gigabytes. Para o .cstaff o pacote sai recortado na sede dele | | /contest/animeitor/placeholder?contest=<c> | GET/POST | o padrão do contest: GET → {custom,bytes,mtime, music:{custom,bytes,mtime}} (o topo é a FOTO — contrato antigo); POST {file_b64} troca a foto (webp 1000px + miniatura), {kind:"music", file_b64} troca a música (mp3, máx 15 MB); POST {action:"reset"[,kind]} volta à de fábrica. kind fora de photo\|music → 422 kind_invalid. Auditado (animeitor-placeholder). A sede (.cstaff/.staff) faz só o GET — o padrão é do contest inteiro (POST → 403) | | /contest/animeitor/webcast?contest=<c> | GET | {keys:[{id,key,view,label,created_by,created_at,revoked_at,fetches,last_at,last_ip}], views:[{id,name}], url_path, contest} — a chave aparece em claro (é o que se copia p/ o Animeitor) | | /contest/animeitor/webcast?contest=<c> | POST | {action:"create", view, label?}{key, view} · {action:"revoke", id}. Visão inexistente → 422 view_invalid. Auditado (webcast-key) |

Admin do contest (logado como .admin daquele contest)

Rota Método I/O
/contest/admin/config?contest=<c> GET {name,mode,start,end,letters[],colors,regions,teams_meta,basic:{locale,login_start,login_enabled,freeze}}
/contest/admin/config?contest=<c> POST {colors?,regions?,teams_meta?,basic?} → grava balloons.json (escritor único cc_balloons_write; {} não mexe, null apaga)/regions.json/teams-meta.json + vars basic no conf (vazio = reseta). basic.freeze:0 (descongelar) só a partir do fim geral + 1 min (409 freeze_locked, ver /contest/admin/settings)
/contest/admin/users?contest=<c> GET {users:[{login,fullname,email,admin,disabled,disqualified}],shared} (sem senha)
/contest/admin/user-add?contest=<c> POST {login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?} → adiciona/reseta, devolve a credencial. fullname é o nome do time (campo único — usuário de contest É o time); os campos de TIME mesclam no .team do account.json
/contest/admin/users-bulk?contest=<c> POST carga em lote {users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}], on_existing?:skip|update} (default skip; ≤5000; senha vazia = gerada). fullname é o nome do time (campo único); os campos de TIME (opcionais) gravam o .team{univ_short,univ_full,flag,region}carga única de credenciais+país+sede+universidade (a UI aceita CSV com cabeçalho: login,senha,nome,pais,sede,univ,univ_nome, ordem livre — time/equipe são aliases de nome). update: senha vazia = regenerada (semântica de reset em massa); nome/email só sobrescrevem se vierem na linha (linha parcial de enriquecimento — ex.: login+sede — não clobbera o nome do time) e os campos de time mesclam; conta privilegiada existente (.admin/.judge/.cjudge/.staff/.mon) nunca é tocada (skip privileged); criar privilegiada nova é permitido. → {created:[{login,password,fullname,email}],updated:[…],skipped:[{login,reason:exists|privileged|invalid|duplicate}],counts}. Auditado users-bulk
/contest/admin/user-remove?contest=<c> POST {login} → remove (mv p/ .removed-users/, dados preservados; toca .score-dirty — o placar o esquece sozinho; não pode remover a si mesmo)
/contest/classification?contest=<c> GET público (gate de secreto igual ao placar; sessão OPCIONAL)
/contest/admin/classify?contest=<c> POST/GET admin
/contest/admin/virtual?contest=<c> GET/POST admin
/contest/admin/user-disqualify?contest=<c> POST {login, undo?}DESCLASSIFICA (.disqualified=true no account.json): a conta continua existindo/logando, mas some do placar (sc_users) e da estatística (stats-gen pula o login por inteiro — placar e estatística sempre contam a MESMA população). undo:true reverte. Não mexe em senha/sessões (desclassificar ≠ desabilitar). Auditado user-disqualify

Reusa os editores de web/shared/contest-config/ (os mesmos da criação). Bandeiras locais/offline em /shared/flags/ (271 países + 27 estados); GIFs do Sonic em /shared/assets/sonic/. USERS_FROM=<contest> no conf faz o login cair no passwd compartilhado (ex.: treino), mantendo o .admin próprio.

Admin / Judge / Ops (Bearer + papel)

Rota Método Papel Ação
/contest/allsubmissions?contest=<c> GET admin/chief/judge/mon TXT 9 campos (tempo:username:problemid:lang:verdict:epoch:subid:fullname:univ). Admin/chief = completo. .judge puro e .mon = ANÔNIMO: campos 2 (username), 8 (fullname) e 9 (univ) vazios, aridade mantida, linhas ordenadas por epoch (o corte é na API — curl não descobre quem submeteu; anonimato é só desta rota)
/contest/final-verdicts?contest=<c> GET/POST GET=judge; POST=admin/chief opções de veredicto manual com 3 campos (2026-09-14): label = o que o JUIZ escolhe (≤80); verdict = CLASSE canônica, uma das 6 de lib/verdict.sh (Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compilation Error) — é o que pontua/penaliza/colore (422 verdict_invalid fora das 6); team = texto que o TIME vê (≤60, sem :/¦; vazio = a classe; Accepted nunca leva team). GET → {verdicts:[classes], classes:[as 6], options:[{label,verdict,team}]} (arquivo antigo com verdict fora das 6 é lido como classe Wrong Answer + team = a string antiga). O history recebe classe¦team (rv_canon_verdict); quem fala com o time usa canon_team/vteam, quem pontua usa canon/vcanon. Default = as 6 (6-Contact staff = Wrong Answer + team). Auditado (final-verdicts-set)
/contest/auto-verdicts?contest=<c> GET/POST GET=judge; POST=admin/chief matriz de veredicto automático { "<cid>": { "<lang|*>": ["<verdict>"] } } (problema × linguagem × veredicto). GET → {matrix,problems,verdicts}; POST {matrix} (cids validados; lang minúsculo ou *). Auditado (auto-verdicts-set)
/contest/review/list?contest=<c> GET judge fila de revisão manual {manual, options, items:[{id,login(**admin/chief**; null p/ juiz comum — anonimato),problem_id,lang,computed_verdict,status,conflict,created_at,claimants:[{by,elapsed_s,expires_in_s}],votes_n,my_vote,votes(**admin/chief**; oculto p/ juiz comum — anti-anchoring)}], counts:{not_evaluated,being_evaluated,awaiting_second,conflicts}, my_active, quorum}quorum = nº de juízes que validam cada veredicto (conf REVIEW_JUDGES, 1..5, default 2); awaiting_second = com voto(s) mas abaixo do quórum
/contest/review/claim?contest=<c> POST judge {id,action:claim|extend|giveup} — máx 2 avaliadores, 1 ativa por juiz (409 already_evaluating/slots_full), TTL 5 min (extend=+5). Rejeita quem já votou (already_voted). Auditado (review-claim/extend/giveup)
/contest/review/vote?contest=<c> POST judge {id,label} — registra o voto (permanente) e libera o juiz (sai dos avaliadores → pode pegar outra); rejeita voto repetido (already_voted). 2 iguais → libera ao aluno (enfileira setverdict, review-agree); 2 diferentes → conflict (review-conflict)
/contest/review/resolve?contest=<c> POST admin/chief {id,verdict} (label ou classe da lista) — o juiz-chefe resolve o conflito; libera ao aluno com classe¦team quando a opção tem texto. Auditado (review-resolve)
/contest/review/conflicts?contest=<c> GET admin/chief sumário dos conflitos {conflicts:[{id,login,problem_id,lang,sub_epoch,computed_verdict,votes:[{by,label,verdict}]}], n, options} (lang/sub_epoch p/ abrir log + código na resolução) — n alimenta o alerta global de conflito (banner + bip) que segue o chief/admin em qualquer página (shared/chief-alert.js, disparado via auth.status)
/contest/review/stats?contest=<c> GET admin/chief estatística por .judge (do admin-audit.log) {judges:[{judge,votes,avg_response_s,timed,agreements,conflicts}], total:{votes,avg_response_s}} — nº de veredictos, tempo médio claim→voto, concordâncias e conflitos; alimenta a aba Situação do juiz-chefe
/contest/set-verdict POST admin ou juiz-chefe {contest,problem_id,verdict,username} — override direto (modo legado/auto-resposta); verdict = label OU classe da lista configurada (rv_canon_verdict; string livre = 422 verdict_invalid desde 2026-09-14); consumido pelo daemon (setverdict) e finalizado pelo escritor único
/contest/rejudge POST admin/chief {ids:[…]} — marca cada submissão como pendente e RE-JULGA (o daemon reconstrói a fonte arquivada + metadados do history)
/admin/adduser POST admin {contest,login,fullname,email?,password?} (gera senha)
/admin/passwd POST admin {contest,login,newpass}
/admin/contest/extend POST admin {contest,end_epoch}
/admin/synctreino POST admin sincroniza treino
/admin/rejudge POST admin {ids:[…]} ou {contest,problem}
/ops/queue GET admin tamanho da fila por contest
/ops/problemtl?problem=<p> GET admin time limits do problema
/ops/updateproblemset POST admin {repo}
/ops/alerts GET/POST bot POST {ack:[{id,ok,error}]} = o bot confirma as entregas do poll anterior (2026-09-14): item .json sai do outbox p/ run/alerts/inflight/<id>.json no claim e VOLTA ao outbox se não houver ack em ALERT_INFLIGHT_TTL (600 s); o ack do relatório de quartil é o que marca sent (rel_ack; ok:false grava last_auto.outcome="failed: …" e o quartil segue devido). O .txt de incidente continua at-most-once. GET: avalia incidentes (juiz offline+fila, fila grande, daemon caído, bot fora do arbot_gone, que só enfileira a mensagem na VOLTA) com histerese/cooldown e drena o outbox: {items:[{id,text,chats:[<chat_id>…],loud,group}]} (no máx. ALERT_CLAIM_MAX=30 por poll — o Telegram corta acima de ~30 msg/s; o resto sai no poll seguinte). O bot só entrega (+ grupo, exceto quando group:false = mensagem dirigida a UMA pessoa; loud:true = com notificação). Efeito colateral: toca run/alerts/bot.alive (heartbeat do bot — vira o campo bot do /index/status e a linha 🤖 do /status/) e roda a varredura do convite de time pendente (inv_sweep_all, stamp próprio a cada INVITE_SWEEP_THROTTLE=300 s: manda o "último aviso" quando falta ≤ REG_REMIND_LEAD p/ a inscrição fechar) e o relatório de quartil (rel_sched_check, stamp próprio a cada RELATORIO_SWEEP_THROTTLE=3600 s: quartil do semestre vencido e não enviado ⇒ gera e enfileira o painel só para o grupo via alert_group — item {chats:[],group:true}, o único destino é o ALERT_GROUP_CHAT do bot). Estado em run/alerts/; sem cron (o poll do bot é o relógio)
/ops/relatorio POST bot painel de submissões p/ o grupo dos professores (comando /relatorio do mojinho). Body {telegram_id, args:[…], chat_id?, chat_type?} (de onde o comando veio; o bot manda desde 2026-09-14). aqui (só com chat_type group/supergroup; 422 not_group) grava chat_id em relatorio.json — o envio automático passa a ir SÓ para esse grupo (alert_dm com chats:[chat_id], group:false); sem chat_id cai no ALERT_GROUP_CHAT do bot (alert_group). refazer <k> desmarca sent[k..4] (o próximo sweep reenvia). status mostra destino e last_auto {k,at,outcome}. Admin ANÔNIMO no grupo (from.id = GroupAnonymousBot) recebe 403 anonymous_admin com a explicação. Trilha em run/alerts/relatorio.log; o sweep do /ops/alerts carimba o stamp DEPOIS do trabalho (falha = retenta em 10 min). o gate é PELO telegram_id: só conta .admin do treino com Telegram vinculado (o mesmo conjunto que recebe alertas; 403 admin_required). args: vazio = relatório do semestre configurado [inicio, agora] (409 not_configured/not_started) · AAAA-MM-DD = override pontual [data, agora] (400 bad_date) · config <ini> <fim> = grava o semestre em contests/treino/var/relatorio.json (quartis passam a ser enviados automaticamente pelo sweep acima; os JÁ vencidos entram pré-marcados — sem spam retroativo; 422 bad_date/bad_range) · status = config + quartis + enviados + próximo. Resposta {html} (Telegram HTML): top-10 de contests por submissões no período (treino em linha própria, privilegiados excluídos), usuários ativos, vs mesmo período do ano anterior e YTD vs anterior. Gerador score/relatorio-gen.sh (uma passada em todos os users/*/history), cache com TTL 600 s em var/relatorio-cache.json. Base fria: a geração síncrona tem orçamento de REL_SYNC_BUDGET=50 s (frio já mediu ~70 s no prod, quente ~3 s); estourou ⇒ termina em background e a resposta vem {html:"⏳…", pending:true} — repetir o comando em ~1 min serve do cache. O sweep de quartil usa cache PRÓPRIO (var/relatorio-cache-auto.json, janela de until fixo = imutável, exact-match sem TTL) e simplesmente envia no sweep seguinte

As rotas admin/* e ops/* (exceto ops/alerts e ops/relatorio, que usam bot-token) são consumidas pelo painel admin e pelo moj-cli. O mojinho-bot hoje é transporte fino: usa só treino/signup/*, treino/recover-password, ops/alerts e ops/relatorio (todos bot-token mojb_…), + /index/status (público).

Status do sistema (público)

Rota Método Auth I/O
/index/status GET health: {queue:{total_pending,spool_queued,band_queued,lists[]}, judge:{online,total,busy,healthy,cpus_online,gpus_online}, alert:{no_judges}, daemons:{judged}, bot:{alive,last_poll_age_s}|null} (cache 20s) — base da página /status/. gpus_online conta SÓ juízes com GPU de compute comprovada (registro com vendor nvidia/amd, vindo de nvidia-smi/rocm-smi; adaptador de display/lspci não conta). daemons.judged = processo local (pgrep) ou heartbeat fresco em run/judged.alive (≤JUDGED_ALIVE_TTL, 120s) — no deploy podman a API e o daemon estão em containers diferentes e o pgrep nunca o veria. bot = saúde do bot de alertas (mojinho): mtime de run/alerts/bot.alive (tocado a cada poll do bot em /ops/alerts); alive = último poll ≤180s; null = instalação sem bot (não é incidente). Quando o bot fica >5 min sem polar e volta, alerts_evaluate (bot_gone) enfileira UMA DM aos .admin com o período fora do ar — o carteiro não avisa a própria morte, mas avisa a ressurreição

Criação de contest (treino)

Permissão: usuários .admin sempre podem; demais por lista do admin OU threshold de problemas resolvidos no treino (com denylist). O contest entra no ar imediatamente. Problemas vêm do banco público (bank_id), por ID (source+problem_id, p/ não-públicos) e/ou com enunciado custom (em cada item name — ou title — é OPCIONAL: sem ele o nome do problema no contest é o título do banco, nunca o id; contests criados antes de 2026-09-18 sem name ganham o título na listagem /contest/problems) — manualmente ou sorteados por tag/dificuldade. Usuários: compartilhados do treino (users_from=treino; login pela conta do treino, via fallback de verify_password) ou próprios (users[], senhas geradas se em branco). O admin do contest é sempre criado (sufixo .admin garantido). Problema PRIVADO (no topo OU em modules.rodadas.rounds[].problems) que o criador não pode ver ⇒ 404 problem_denied sem listar ids (idem no duplicate, com o duplicador como sujeito). O spec unificado é VALIDADO como os painéis: id de módulo desconhecido, regex de coorte/prorrogação/gate que não compila (ou >200 chars), rodada com fim ≤ início / freeze fora da janela / kind fora de warmup|official|extra, visão de telão inexistente ⇒ 422 modules_spec_invalid; seção com on:false é ignorada (2026-09-15). Exige ao menos um problema — sem isso, 422 no_problems; para criar vazio e configurar depois mande allow_empty:true (booleano estrito; na web é o botão "Criar vazio", na CLI a flag moj contest create --empty). Acrescentar problemas depois não tem restrição (/contest/admin/problems, com o contest já no ar). | Rota | Método | Auth | I/O | |---|---|---|---| | /treino/contest-create/permission | GET | Bearer | {can_create,is_admin,is_superadmin,reserved_id_prefixes:["icpc"],reason,solved_count,threshold,in_allow,in_deny,allowed_modes,login,name}. is_superadmin = login está em SUPERADMINS do conf do treino; só ele cria contest com id icpc* (o create/duplicate respondem 403 id_prefix_reserved aos demais). | | /treino/contest-create/problems?q=&limit= | GET | Bearer+criador | autocomplete dos problemas que o criador pode usar: públicos + os privados a que tem acesso (dono, colaborador ou membro da org) {problems:[{id,title,tags,access:mine\|shared\|public,private}],mine,shared,total}. Privados primeiro; statement vem de var/jsons-private/. /create recusa problema privado sem acesso (problem_denied) e auto-valida (enfileira index) os privados sem enunciado pronto — o contest mostra o enunciado assim que o juiz indexa (contest/problems faz fallback p/ jsons-private e cacheia) | | /treino/contest-create/tags | GET | Bearer+criador | tags do banco com contagem {tags:[{tag,count}],total} | | /treino/contest-create/collections | GET | Bearer+criador | coleções do banco público com contagem {collections:[{collection,count}],total} (escopo ≠ /problems/collections, que conta sobre os problemas do login) | | /treino/contest-create/draw?tags=&collections=&count=&match=any\|all&difficulty=any\|easy\|medium\|hard\|known&seed= | GET | Bearer+criador | sorteia problemas por tag, coleção e dificuldade (filtros em AND; dificuldade = taxa POR USUÁRIO de lib/difficulty.sh: easy = muito fácil+fácil (≥70 % de quem tenta resolve), medium = 50–70 %, hard <50 %, unknown sem tentantes — cada item traz difficulty, user_rate, attempters, bucket), reproduzível por seed {problems[],candidates,drawn,seed,collections}. collections = array JSON url-encoded (nome de coleção é texto livre — pode ter vírgula/espaço); casa exato; inválido/ausente = sem filtro | | /treino/contest-create/genpass?n= | GET | Bearer+criador | N senhas legíveis (palavras-para-senha) {passwords[]} | | /treino/contest-create/create | POST | Bearer+criador | {id?,name,mode,priority?,start?,end,languages?,allow_empty?, admin:{login?,password?,fullname?}, (users_from? \| users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}]), problems:[…], **modules?:{: true | {on?, …seção…}}** (**mode** = icpc(default) \|obi\|treino\|heuristic\|outro(só.admin, 403 mode_forbidden); inválido = 422 mode_invalid; **não muda depois da criação** — exporte o spec, edite e recrie. SPEC UNIFICADO — um JSON levanta o contest inteiro: sedes{regions,teams_meta,time_overrides}, baloes{colors,during_freeze}, coortes{cohorts}, maquinas{ua_gate,site_lock{enabled,grace},nutella_url}, rodadas{active,rounds}, documentos{config}, inscricoes{enabled,window{open,close,late_minutes,team_max,teams,warmup_open}}, telao{views[{view,label}]}→ chaves NOVAS de webcast,classificacao{algorithm,config}→ stage em rascunho; seção presente = módulo ligado salvoon:false; tipo errado = 422 modules_spec_invalid; SEGREDO nunca entra), compat: colors?:{A:"RRGGBB",…,enableSonic?}, regions?:[…], teams_meta?:[{regex,country,school?,school_full?}]no topo continuam aceitos (ligambaloes/sedes), locale?,login_start?,login_enabled?,freeze?, show_log?,show_editor?,show_tl?,allow_backup?,allow_print?,score_anon?,manual_verdict?,allow_late?,secret?,login_ua_substring?,score_full_users?,penalty_minutes?,penalty_verdicts?}{contest_id,admin_login,admin_reused,admin_password,users[],users_from,url,scoreboard_url}. Paridade com o settings: os toggles/opções espelham /contest/admin/settings (grava só o não-default). languages aceita array de ids canônicos (normaliza como o settings) ou string legada. priority = prioridade no escalonador (prova/lista-privada/lista-publica; super só admin — como mode:outro). judges[] = pool de juízes do contest (→ CONTEST_JUDGES; entra também no template/export/duplicate). Por problema: languages[] (vira problem-langs.json), judges[] (vira problem-judges.json) e statement_pdf_b64/statement_pdf_file (além do HTML). Admin não é sobrescrito: senha digitada é respeitada; em modo compartilhado, se o <login>.admin já existe na fonte users_from ele é reutilizadoadmin_reused:true, admin_password:null | | /treino/contest-create/template | GET | Bearer+criador | baixa template JSON completo (documenta todos os campos do create, incl. toggles/priority/users/visual) | | /treino/contest-create/import | POST | Bearer+criador | {tar_b64} (.tar.gz com contest.json + enunciados/) → cria | | /treino/contest-create/templates | GET/POST | Bearer+criador | templates nomeados por criador (treino/var/contest-templates/<login>.json). GET lista os meus (?name= → 1, senão 404). POST {op:save,name,(template{}\|from_contest,include_problems?)} | {op:delete,name} | {op:rename,name,new_name}. O spec salvo é relativizado + whitelist no servidor: datas viram duration/login_lead/freeze_before_end; nunca guarda usuários/senhas/id/datas absolutas; a seção modules{} entra sem o que é preso a data (rounds, active, time_overrides). from_contest: só dono do contest ou admin (senão 404). Limites: 20/usuário, spec ≤64KB | | /treino/contest-create/export?id=&full_statements=0\|1 | GET | Bearer+criador | baixa o spec JSON de um contest existente (formato do /create — round-trip). Traz a seção modules{} dos módulos LIGADOS com os dados reeditáveis (regions, cores, coortes, gate de UA, plano de rodadas sem as arquivadas, config de documentos sem published, janela de inscrição, views do webcast SEM chave, algoritmo/config da classificação) — nada de regions/colors/teams_meta no topo. Gate: created-by + (dono ou admin) — senão 404. Nunca exporta passwd/users/senhas/submissões. Enunciados: default embute só o material exclusivo do contest (sem json público no banco); full_statements=1 embute tudo | | /treino/contest-create/duplicate | POST | Bearer+criador | {from, id?, name?, start?, end?, admin?, users?\|users_from?} → cria contest novo copiando conf+problemas+módulos do from (usuários/submissões nunca; enunciado custom copiado por arquivo; o PLANO de rodadas anda junto com as datas — mesmo delta —, prorrogações por sede não viajam, o webcast ganha chaves novas). Datas: start=agora, end=start+duração original; login_start/freeze relativos preservados; name default "Cópia de …". Gate do from = o do export (404) | | /treino/contest-create/mine | GET | Bearer+criador | contests criados por mim (owner==login + created-by) {contests:[{id,name,mode,created_at,start,end,problems_count}],total} (admin usa /treino/admin/contests p/ a lista completa) | | /treino/admin/contest-perms | GET/POST | admin | GET {perms:{threshold,allow[],deny[],allow_meta{},deny_meta{}}, allow_info:[{login,name,has_photo,by,by_name,at,note}], deny_info:[…], me} — a trilha de quem liberou/bloqueou e quando (2026-09-15). POST por ação: {action:"add",list:"allow"\|"deny",login,note?} (conta tem de existir no treino: 404 unknown_login; *.admin na allow: 422 already_admin; sai da lista oposta; carimba by/at), {action:"remove",list,login}, {action:"threshold",threshold}. POST legado {threshold,allow[],deny[]} (substituição total) segue aceito e carimba meta nas entradas novas. Resposta = a do GET + saved. | | /treino/admin/contests | GET | admin | contests criados pela interface que este admin pode ver (cc_contest_visible_to, 2026-09-15): super-admin (SUPERADMINS no conf do treino) vê todos (scope:"all"); .admin comum vê os seus e os de criadores sem papel de admin (scope:"admin") — nunca o contest de outro .admin. {contests:[{id,name,mode,owner,owner_name,owner_has_photo,owner_is_admin,created_at,start,end,problems_count}],count,scope,me,is_superadmin} | | /treino/admin/contest-remove | POST | admin | {contest} → move p/ lixeira (só os criados pela interface); contest fora do escopo acima = 404 (não confirma a existência). duplicate/export do criador seguem o mesmo escopo. |

Ações auditadas (em treino/var/admin-audit.log): contest-create, contest-template, contest-export, contest-perms, contest-remove — além de news-*, logout-*, lock-user.

O CLI moj-contest (web/moj-contest, servido em GET /moj-contest; fonte em moj-cli/; moj contest … delega a ele) cobre estas rotas e as de /contest/admin/*: criação (spec/ template), templates nomeados, export/duplicate, settings, problemas (com sorteio por coleção), usuários, sessões, auditoria, remoção e os documentos da prova (docs ls|gen|get|publish|unpublish|cover|set|textls/get valem p/ QUALQUER conta do contest, então a sede (.cstaff) baixa o publicado pelo terminal, útil em rede isolada). Sessões: criação/reuso = token do treino (moj login); administração = token daquele contest (moj-contest login <cid>, conta *.admin do contest) — o corte de acesso é sempre o do servidor.

Ambiente de contest (subdomínio + admin do contest)

Acessado por <id>.moj.<base> (subdomínio): o nginx injeta CONTEST_HOST; a API só serve aquele contest (auth/contest/submit/submission) e o frontend redireciona o resto para /contest/. ⚠ Esse isolamento vale só para quem ENTRA pelo subdomínio — da máquina de prova, curl --resolve moj…:443:<IP> chega ao site base pelo mesmo IP; quem fecha isso é a trava de sede por IP (/contest/admin/site-lock, 403 site_locked). Login com gate opcional por substring de User-Agent (LOGIN_UA_SUBSTRING, só não-privilegiados). Papéis: .admin/.judge/.cjudge (juiz-chefe, herda juiz)/.staff/.mon.

Rota Método Papel I/O
/contest/admin/sessions?contest=<c> GET admin sessões ativas (sessions[]{login,name,ip,user_agent,login_at,mkey,multi_ip,multi_ua}) + alerta de UA/IP diferentes. mkey = chave de máquina (m:<machine_id>/<boot_id> do UA do mlinux, senão ip:<ip>lib/session-index.sh). A varredura SEMEIA o índice de sessões por login quando ele ainda não existe (run/sessions/.idx/<c>/)
/contest/admin/access-log?contest=<c>&day= GET admin log de acessos (epoch/login/ip/UA) + alertas
/contest/admin/site-lock?contest=<c> GET/POST GET admin ou .cjudge; POST só admin trava de sede por IP (lib/site-lock.sh; conf SITE_LOCK=1, SITE_LOCK_GRACE s, default 3600). Com a trava, todo login de COMPETIDOR reivindica o IP de origem p/ o contest até CONTEST_END+grace (estado run/site-lock/<ip>, uma linha por contest); o router.sh responde 403 site_locked a qualquer pedido daquele IP a outro alvo (treino, índice, /problems, outro contest — inclusive sessão antiga), exceto conta de papel e auth/logout. É o que fecha curl --resolve da máquina de prova ao site base. Auditado: 1ª reivindicação de cada IP (site-lock-claim ip= login= until=) e cada bloqueio (site-lock-block ip= target= route= login=, teto 1/5 min por ip+alvo; o contador blocked sobe sempre). GET → {enabled, grace, claims:[{ip,until,first,last,logins,blocked,last_block,last_target,active}], blocks:[…do audit…], claims_audit:[…]}. POST {action:"set", enabled, grace?} · {action:"release", ip} · {action:"claim-seen"} (prende os IPs de competidor vistos na janela da rodada, aquecimento incluso). Painel: Pessoas › Sessões & anomalias (tabela + bloqueios) e a chave em Máquinas & gate › 🔒
/contest/admin/anomalies?contest=<c>[&round=<slug>] GET admin ou .cjudge anomalias de uso de máquina DURANTE a prova (lib/anomalies.sh; painel Pessoas › Sessões & anomalias). SÓ vale com o gate de UA ligado (gate.active; sem gate só counts.sessions e a trilha). → {gate:{mode,single_session,active}, round, window:{start,end,since}, computed_at, session_classes:{competitors,staff,privileged}, counts:{sessions,teams_live,multi_session,machine_shared,sub_other_machine,reboot,ua_mismatch,site_short,switched,revoked,events}, anomalies:[{kind,severity:bad|warn|info,at,login,name,region,machine,detail}], events:[…sessão única/logout…], teams:[{login,name,region,sessions[],machines[],last_sub:{at,key,same_as_session,same_as_login_machine},flags[]}], machines:[{key,logins[],shared,live}], sites:[…sede com menos máquinas que times…], channels:{logins:{web,cli,other}, submissions:{web,cli,other,offline}}, nutella_at} (channels vale mesmo sem gate: canal pelo UA — a CLI se marca moj-<tool>/<build>, navegador começa por Mozilla/; offline = pacote do /contest/offline-submit). events[] inclui kind:"site_lock" (reivindicações e bloqueios da trava de sede lidos do audit; counts.site_lock_blocks/site_lock_claims). Tipos: multi_session (2 sessões vivas em máquinas diferentes), machine_shared (2+ times na mesma chave na prova; bad se ambos vivos), sub_other_machine (requisição de chave ≠ da sessão; mesmo machine_id com boot diferente = reboot, info), ua_mismatch, site_short (cache do nutellaboot), switched, session_event. Só a chave m: (UA do mlinux) identifica máquina: login com chave ip: (navegador comum; atrás de NAT o IP é a sede inteira) só entra em ua_mismatch e nas sessões. Fontes: var/access.log, var/submit-origin.log, var/session-events.log, sessões vivas (índice), ua-gate.json, var/nutella.cache.json. Cache de resposta 15 s por rodada; identidade dos times (var/.an-users.json) e esperado do gate (var/.an-exp.json) em cache de 5 min; extrato do nutellaboot (var/.an-sites.json) invalidado pelo próprio cache; sessões lidas do índice com UM grep (sem source por sessão). Com gate.active:false, teams[] vem VAZIO (o painel esconde a tabela; eram 1,3 MB na LATAM). session_classes conta TODAS as sessões vivas do contest por classe (a caixa "sair em massa" lê daqui)
/contest/admin/audit-log?contest=<c>&since=&action=&user=&limit= GET admin feed unificado (trace no instante exato de cada evento) {events:[{time,who,kind,action,details}],count}. 4 fontes: admin (var/admin-audit.log), login (var/access.log), submit (1 por submissão, no sub_epoch do users/<login>/history), verdict (1 por correção, no finalized_at do users/<login>/results/<subid>.json — traz o juiz; who = o aluno). Cada submissão gera 2 entradas: a submissão (quando o aluno enviou) e o veredicto (quando o juiz respondeu); pendente = só a submissão. As 4 fontes viram NDJSON num temporário e saem numa passada de jq --slurpfilenunca --argjson (o array do admin-audit sozinho passa dos 128 KiB de MAX_ARG_STRLEN)
/contest/admin/dashboard?contest=<c> GET admin situação ao vivo: {judges:{online,busy,total,queue_depth,assigned,pool[],list[]}, routing, submissions:{total,pending,pending_list[],max_wait_s,response:{avg_s,max_s,p50_s,p95_s},timeline[]}} (routing = shards do escritor, mesmo shape do /treino/admin/queue) (janela = últimas N submissões; pool = hostnames de CONTEST_JUDGES, [] = sem pool — o front marca ⭐ os hosts do pool e alerta pool offline)
/contest/admin/settings?contest=<c> GET/POST admin (show_code foi REMOVIDO em 2026-09-18: o GET não o devolve; o POST aceita e IGNORA a chave — cliente antigo manda o formulário inteiro — e apaga a linha SHOWCODE do conf.) GET traz também modules[] (ids ligados; muda-se em /contest/admin/modules) e statement_langs/statement_langs_mode (auto|list)/default_statement_lang (só leitura aqui; muda-se em /contest/admin/statement-langs). Tempos, login on/off, abertura, freeze (**freeze:0 = DESCONGELAR só a partir de freeze_release_at = contest_end_all + 60 s, prorrogações por sede incluídas — 409 freeze_locked com a hora na mensagem; vale p/ TODOS os caminhos que zeram o freeze: este, config basic.freeze, finish, promoção de rodada e rounds set na ativa (freeze_change_guard, comparação NUMÉRICA — "00" é zero); empurrar um freeze JÁ EM VIGOR para depois de agora também é descongelar (409); mover o freeze antes de ele entrar em vigor é livre; o GET traz freeze_release_at p/ a UI mostrar a hora — regra de 2026-09-14), locale, tz (fuso IANA da prova → CONTEST_TZ; vazio/null volta ao MOJ_TZ da instalação, 422 tz_invalid se não existir no zoneinfo — governa TODA hora que o SERVIDOR escreve p/ gente sobre o contest: DM do mojinho, preflight, caderno, relatório; a web sempre mostrou no relógio do browser), toggles show_log/show_editor/show_tl/allow_late/score_anon/allow_backup/allow_print/manual_verdict/secret, login_ua_substring, languages[] (whitelist do contest), judges[] (pool de juízes do contest: hostnames do registro, vazio = qualquer juiz online; vira CONTEST_JUDGES no conf — o job leva allowed_hosts e o escalonador é ESTRITO: pool offline segura a fila; o TL de /contest/problems passa a ser só do pool), score_full_users[] (logins que veem o placar completo além de .admin/.judge/.cjudge). Penalidade ICPC: penalty_minutes (int, default 20) e penalty_verdicts (array de códigos wa/tle/mle/rte/ce, default sem ce) — quais verdicts contam penalidade e o peso por tentativa; Judge Error/pendentes nunca contam; mudar freeze/penalidade dispara rebuild FORÇADO (score_kick_rebuild — imune à corrida de mtime com build em voo; ver /contest/admin/finish). O GET devolve também mode (read-only, modo do placar). show_log é o valor EFETIVO: em modo icpc com SHOWLOG ausente do conf o default é false (o report expõe os testes — anti-vazamento); no POST, show_log:true grava SHOWLOG=1 explícito (religar fica registrado) e false grava SHOWLOG=0. secret = SUPER SECRETO (fora das listagens públicas; placar/visual exigem login no contest; a UI exige digitar o id p/ desmarcar). manual_verdict (opt-in, default OFF) liga o veredicto manual: o daemon SEGURA o veredicto computado p/ revisão de juízes humanos (exceto o que a matriz auto-verdicts libera). review_judges (int 1..5, default 2 = ausente do conf; vira REVIEW_JUDGES) = QUANTOS juízes validam cada veredicto — N votos unânimes liberam; divergência vira conflito p/ o chief; 1 = revisão simples. Desligar manual_verdict VARRE a fila de revisão: o que ninguém contestou (sem voto e sem conflito) é liberado com o veredicto COMPUTADO — senão as sobras ficavam presas p/ sempre (o juiz comum não consegue mais votar e o competidor fica em Not Answered Yet); item com voto ou em CONFLITO não é atropelado e fica p/ o juiz-chefe. A resposta traz review_released/review_pending; auditado review-manual-off. balloons_during_freeze (bool, default false = retém) = entregar balão com o placar CONGELADO. Default protege o freeze: AC feito no congelamento não vira tarefa de entrega e não é entregue depois (ver /contest/staff/queue). LIGAR libera retroativamente o que ficou retido (apaga as lápides + o stamp; o próximo carregamento da fila materializa tudo — id determinístico, não duplica) e a resposta traz balloons_released; auditado balloon-freeze-release. O GET traz também balloons_frozen = quantos estão suprimidos agora. balloon_style (icon|fill, default icon = SCORE_BALLOON_STYLE ausente do conf; 422 balloon_style_invalid) = como a célula "resolveu" é pintada no placar/cerimônia/relatório (ver SCOREBOARD.md). guest_numbering (bool, GUEST_NUMBERING; issue #25) = convidados (coorte unranked) numerados na sequência própria — a 1ª linha do TXT da visão com convidados vira icpc s g; mudar dispara rebuild
/contest/admin/seed?contest=<c> POST admin do contest, e só com DEMO=1 no conf (senão 403 demo_required) povoa um contest de DEMONSTRAÇÃO com times e submissões SINTÉTICAS — existe para quem desenvolve o Animeitor (ou qualquer cliente de placar) ter um placar de verdade para trabalhar sem uma prova acontecendo. Body (tudo opcional): {teams:20, submissions:200, seed:1, freeze_minute, window_minutes, password:"demo1234", verdicts:{accepted,wrong,tle,rte,ce,pending}}. Cria os times que faltarem (time-01…N, com .team sigla/bandeira/sede) e escreve as submissões pelos mesmos escritores do veredicto real (user_history_append + metrics_recompute + score/build.sh) — o resultado é indistinguível para placar, estatística, webcast e balões. Determinístico pelo seed (mesmo seed ⇒ mesmo placar; a janela é arredondada a minuto cheio e window_minutes a fixa). freeze_minute grava o FREEZE_TIME (é o que faz placar.txt diferir de placar-full.txt). O probid gravado é o canônico (PROBS[i+4]) — qualquer outra grafia deixaria a célula em branco no placar em silêncio. Limites: teams 1..500, submissions 0..20000. É ADITIVO: chamar de novo soma ao que já existe (times que já existem não são recriados) — para começar do zero, apague o contest e crie outro. Resposta: {teams, teams_created, submissions, seed, freeze_time, window_minutes, password, board_lines, runs_after_freeze, hint, by_verdict{}}runs_after_freeze:0 com freeze pedido vem com hint: o congelamento caiu na borda da janela semeada, o placar congelado sai igual ao completo e não há revelação para testar. Auditado (seed). ⚠ a marca DEMO=1 só é gravada na CRIAÇÃO do contest (demo:true no spec do /treino/contest-create/create) — não há toggle que a ligue depois
/contest/admin/problems?contest=<c> GET/POST admin GET inclui languages e judges por problema; {action:add|remove|reorder|rename} (reescreve PROBS) — rename {letter, name?, new_letter?} também troca o IDENTIFICADOR (^[A-Za-z0-9]{1,3}$; em uso = 422 letter_taken; a cor no balloons.json migra junto) e reorder só re-letra pela posição quando as letras atuais são a sequência automática A,B,C,… — identificador customizado (W1…) sobrevive à reordenação, {action:langs,letter,languages[]} (whitelist por problema em problem-langs.json), {action:judges,letter,judges[]} (pool de juízes por problema em problem-judges.json; vazio = herda o pool do contest) ou {action:statement,letter, html_b64?|pdf_b64?|remove_html?|remove_pdf?|refresh?} (enunciado por problema em enunciados/<skey>.{html,pdf}; refresh re-indexa do banco). add de problema PRIVADO: só se o dono do contest (arquivo owner) for dono/colaborador do problema (mesmo guard da criação); senão 404 (não vaza a existência). Contest sem owner (legado): só público
/contest/admin/bank?contest=<c>&q=&limit=&collection= GET admin busca p/ adicionar problemas: banco público + os PRIVADOS a que o dono do contest tem acesso (dono/colaborador no índice — o mesmo sujeito do gate de add; a busca lista exatamente o que pode entrar). Privados primeiro. {problems:[{id,title,tags,collections,access:mine|shared|public,private,has_statement}],total,mine,shared}. Contest sem owner (legado) → só públicos. ?meta=1{tags:[{tag,count}],collections:[{collection,count}]} (agregado do banco público — sorteio é público)
/contest/admin/draw?contest=<c>&tags=&collections=&count=&match=&difficulty=&seed= GET admin sorteio no banco público (mesmo contrato do draw do wizard: coleção/tag/dificuldade em AND, collections = array JSON url-encoded, reproduzível por seed)
/contest/statistics?contest=<c> GET admin/judge/mon totais, por-problema (first_minute relativo ao início + first_seconds p/ desempate + first_solver_name = nome do time de quem resolveu primeiro; estatística nunca mostra só o login, e o nome é resolvido no CACHE porque os dois consumidores — painel e relatório offline — não consultam contas), por-linguagem, veredictos, linha do tempo. Tempo = sub_epoch - CONTEST_START (não EPOCH). Só usuários normais (descarta .admin/.judge/.staff/.mon). Recortes prontos (2026-08-30): by_region:{<sede>: …} e by_country:{<flag>: …} — cada valor tem o MESMO shape do agregado global (totals/problems/languages/verdicts/timeline/dists, first_solver* recalculado DENTRO do recorte), computados na mesma passada do gerador; sede = .team.region e também cada NÓ da árvore de regions.json (país › região/supersede › sede — o nó agrega por REGEX de login, como o regionMatch do placar, com dedup quando nome == sede; regex inválida é descartada), então o seletor de Sede da estatística oferece a MESMA árvore do placar; país = o PREFIXO do .team.flag minúsculo (br-prbr: time brasileiro declara bandeira de ESTADO e "estatísticas do Brasil" tem de juntá-los — o filtro "Bandeira" do placar casa pela mesma hierarquia); conta sem o dado fica fora do recorte correspondente. A UI (/contest/statistics/) expõe dois selects mutuamente exclusivos. População (2026-08-31, relato da LATAM): totals traz enrolled (INSCRITOS não-privilegiados, a mesma população do placar), users (quem submeteu) e absent (a diferença) — no global e em cada recorte; o bucket 0 do problems_solved_dist INCLUI os ausentes (a distribuição casa com o placar). Nó do regions.json com view:true (supersede/femininos — recorte que SOBREPÕE as sedes) sai com view:true na fatia e a UI avisa "não some com as sedes" (⚠ fatia é chaveada por NOME: dê nomes próprios aos recortes). Estatísticas 2.0 (2026-09-01): problems[] ganha avg_ac_min/tries_per_ac/dirt/difficulty (rótulo pelo accept_rate por time, mesmas faixas do treino — lib/difficulty.sh) (métrica do resolver ICPC: % de subs erradas entre quem resolveu)/ac_langs; cada recorte tem dirt; o GLOBAL ganha ac_events ([[login,prob,minuto,tentativas]…], 1º AC de cada time×problema, convidados inclusos — base ÚNICA das seções corrida/comparação/desempenho, que a UI filtra pelo recorte corrente), teams_idx (login→{n:nome,c:país,r:sede} de todo time com AC), penalty_minutes e unranked_regex (a regex das coortes convidadas, p/ o cliente aplicar o MESMO corte do ranking), top_teams (10, oficiais) e performance (média/mediana/quartis/p90 de resolvidos; média/mediana/quartis de penalidade ICPC; first_ac_median) — a UI recomputa desempenho/top 15 client-side por recorte e só mostra o quadro com ≥30 times com AC. Cache em var/statistics.cache.json (server/score/stats-gen.sh), invalidado por history/conf.
/contest/clarifications?contest=<c> GET Bearer role-aware (admin/judge/mon = todas; demais = próprias + públicas, sem answered_by). Quem perguntou (login + asker_name) só o juiz-chefe/admin recebe (2026-09-14, pedido do juiz-chefe); .judge/.mon continuam SEM .login (tratamento isonômico) e o relatório público segue anônimo. Privilegiado recebe answer_claim (reserva expirada já vem null). Envelope: {clarifications, can_answer, can_edit, is_chief, me}can_edit = juiz-chefe OU admin (editam resposta dada, liberam reserva alheia com force); is_chief é compat e vale o mesmo
/contest/clarification-ask?contest=<c> POST Bearer {problem?,question}. Gate de janela (competitor_write_guard, a MESMA do /submit; 2026-09-15): time e .mondurante a prova — antes 403 contest_not_started, depois 403 contest_ended (fim EFETIVO da sessão: sede prorrogada segue perguntando); .staff/.cstaff/.animeitor nunca (403 role_forbidden); admin/juiz/chefe sempre
/contest/clarification-claim?contest=<c> POST admin/judge/mon {id,action:claim|release,force?}reserva p/ responder (dois juízes não pegam a mesma; TTL CLAR_TTL 5 min, expira na leitura). Ninguém reserva por cima de outro (409 clar_claimed, juiz-chefe incluso). release de reserva ALHEIA só com force:true e juiz-chefe/admin (senão 409 clar_claimed); a resposta traz forced_from e o audit clar-release … forced_from=<juiz>. Auditado (clar-claim/clar-release)
/contest/clarification-answer?contest=<c> POST admin/judge/mon {id,answer,public?} — sob flock + reserva; já respondida só o juiz-chefe/admin edita (409 already_answered); abertas exigem a reserva (409 clar_claimed). Auditado (edited=)
/contest/clarification-broadcast?contest=<c> POST admin/judge/mon aviso oficial {problem?,question?,answer}answer é o TEXTO do aviso (obrigatório; 422 answer_missing); question é o ASSUNTO, opcional (a UI o mostra como título). Público, broadcast:true, autor oculto (login:""; UI mostra "Organização"). Auditado
/contest/admin/cohorts?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) coortes de placar (times oficiais × CONVIDADOS/extra-oficiais; motor em lib/cohorts.sh, formato em docs/SCOREBOARD.md). GET → {cohorts:[{id,name,regex,public,unranked,default,sees}], results_released, views, counts:{id:n}, by_regex_only:[login]}. POST {action}: add/set {id,name?,regex?,public?,unranked?,sees?,default?} (regex tem de COMPILAR; máx 8 coortes; a marca default é única e sempre existe) · rm {id} (recusa a default e coorte com time: 409 is_default/cohort_in_use) · assign {login,cohort} (grava .team.cohort; "" devolve o time à regra) · materialize (carimba o campo em quem hoje casa só por regex) · release {on} = liberar os resultados (todos passam a ver todos). Tudo auditado (cohorts-*) e toca var/.score-dirty
/contest/admin/statement-langs?contest=<c> GET/POST admin ou .cjudge idiomas do enunciado que a sanfona oferece (conf STATEMENT_LANGS). GET {mode:auto|list, langs[], default, all:[pt,en,es], locale, available:{<letra>:{<lang>:true}}} (available = idioma com arquivo no contest ou tradução no banco). POST {mode:"auto"} = automático (o default; apaga a var — todo idioma que cada problema tem entra na sanfona) ou {langs:["pt","en"]} = lista fixa (allowlist pt/en/es, 422 lang_invalid; vazia ou só pt = prova só em PT, gravado STATEMENT_LANGS=pt; 400 sem mode/langs). Os dois materializam do banco as traduções que faltam (enunciados/<skey>.<lang>.html), tocam var/.problems-dirty e auditam statement-langs (2026-09-15).
/contest/admin/rounds?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) rodadas: GET → {active, rounds[], next, promote_ready:{ok, blockers:[{code,detail}]}} (a rodada ATIVA é espelhada do conf a cada leitura — editar em ⚙️ Configurações/📚 Problemas nunca diverge). POST {action}: add {slug,name?,kind?,start,end,freeze?} (rodada planejada; kindwarmup|official|extra; freeze tem de cair na janela) · set {slug,new_slug?,…} (renomeia e edita; a ativa vai direto p/ o conf — pelo OBJETO editado, senão o espelho conf→json anularia a edição) · problems {slug,problems[]} (guarda de problema privado = a MESMA de Prova › Problemas e do wizard, problems_denied_for: público, ou o dono do contest é dono/colaborador/membro da org do problema — vale igual p/ rodada ativa e planejada; 403 problem_denied com a lista; era só dono-ou-público até 2026-09-14) · set aceita também colors = cores de balão DA RODADA (formato do balloons.json: {A:"RRGGBB",…,enableSonic:bool}; 422 colors_invalid; null remove — a rodada volta a herdar; {} não mexe): na rodada ATIVA grava o balloons.json na hora (= Evento › Balões, que o GET espelha em colors); na planejada fica no plano e entra no ar na promoção — sem colors, a promoção mantém as cores em vigor; o arquivo rounds/<slug>/balloons.json guarda as cores que a rodada usou · remove {slug} (só pending — arquivada é auditoria) · publish {slug,on} · promote {to?,force?}. Promover = arquivar a rodada ativa + zerar o store + aplicar a janela/PROBS da próxima; recusa com 409 not_ready + blockers (round_running, jobs_in_flight, pending_verdicts, review_pending, judged_down, no_next_round, shared_users), freeze_locked = placar congelado antes de freeze_release_at — fim geral + 1 min; force:true ignora todos menos no_next_round/freeze_locked. Tudo auditado (round-add/set/problems/remove/publish/promote[-forced] Problema na rodada (action:problems): a guarda é problems_denied_for com o DONO do contest como sujeito e a recusa é 404 problem_denied sem listar ids (existência de privado alheio não vaza). action:set na rodada ATIVA passa por freeze_change_guard (freeze 0/"00" ou freeze em vigor empurrado p/ o futuro = descongelar ⇒ 409 freeze_locked). Bloqueador problem_denied (DURO, force não passa) na promoção: a rodada planejada tem problema privado que o dono não pode ver — última porta antes de cc_build_probs materializar o enunciado (a lista pode ter vindo do spec unificado/duplicate). (2026-09-15)